@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// In-memory storage backend. The reference implementation and the one tests run
|
|
2
|
+
// against — it exercises the full contract (multipart, ranges) without touching
|
|
3
|
+
// disk or the network. Not for production (everything lives in a Map), but it
|
|
4
|
+
// keeps the higher layers honest.
|
|
5
|
+
|
|
6
|
+
import { StorageBackend, toBytes, bytesStream, concat, resolveRange } from './interface.js';
|
|
7
|
+
import { TroveError } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
function etagOf(bytes) {
|
|
10
|
+
// Cheap non-cryptographic content tag; good enough to detect changes.
|
|
11
|
+
let h = 2166136261;
|
|
12
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
13
|
+
h ^= bytes[i];
|
|
14
|
+
h = Math.imul(h, 16777619);
|
|
15
|
+
}
|
|
16
|
+
return `"${(h >>> 0).toString(16)}-${bytes.length}"`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class MemoryStorage extends StorageBackend {
|
|
20
|
+
constructor() {
|
|
21
|
+
super();
|
|
22
|
+
this.objects = new Map(); // key -> { bytes, contentType, etag }
|
|
23
|
+
this.uploads = new Map(); // uploadId -> { key, contentType, parts: Map<number, bytes> }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get capabilities() {
|
|
27
|
+
return { presignDownload: false, presignUpload: false, multipart: true, range: true, list: true, usage: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async list({ prefix = '', cursor = null, limit = 1000 } = {}) {
|
|
31
|
+
// Sorted so the cursor (the last key returned) is a stable resume point.
|
|
32
|
+
const keys = [...this.objects.keys()].filter((k) => k.startsWith(prefix)).sort();
|
|
33
|
+
const start = cursor ? keys.findIndex((k) => k > cursor) : 0;
|
|
34
|
+
const from = start === -1 ? keys.length : start;
|
|
35
|
+
const page = keys.slice(from, from + limit);
|
|
36
|
+
return {
|
|
37
|
+
objects: page.map((key) => {
|
|
38
|
+
const o = this.objects.get(key);
|
|
39
|
+
return { key, size: o.bytes.length, etag: o.etag, modifiedAt: o.modifiedAt ?? null };
|
|
40
|
+
}),
|
|
41
|
+
nextCursor: from + limit < keys.length ? page[page.length - 1] : null,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async put(key, body, opts = {}) {
|
|
46
|
+
const bytes = await toBytes(body);
|
|
47
|
+
if (opts.signal?.aborted) throw TroveError.aborted();
|
|
48
|
+
const rec = { bytes, contentType: opts.contentType, etag: etagOf(bytes), modifiedAt: Date.now() };
|
|
49
|
+
this.objects.set(key, rec);
|
|
50
|
+
opts.onProgress?.(bytes.length);
|
|
51
|
+
return { size: bytes.length, contentType: rec.contentType, etag: rec.etag };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async head(key) {
|
|
55
|
+
const rec = this.objects.get(key);
|
|
56
|
+
if (!rec) throw TroveError.notFound('Object');
|
|
57
|
+
return { size: rec.bytes.length, contentType: rec.contentType, etag: rec.etag };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async get(key, opts = {}) {
|
|
61
|
+
const rec = this.objects.get(key);
|
|
62
|
+
if (!rec) throw TroveError.notFound('Object');
|
|
63
|
+
const total = rec.bytes.length;
|
|
64
|
+
const range = resolveRange(opts.range, total);
|
|
65
|
+
return {
|
|
66
|
+
stream: bytesStream(rec.bytes, range && { start: range.start, end: range.end }),
|
|
67
|
+
size: range ? range.end - range.start + 1 : total,
|
|
68
|
+
contentType: rec.contentType,
|
|
69
|
+
etag: rec.etag,
|
|
70
|
+
range,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async delete(key) {
|
|
75
|
+
this.objects.delete(key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- multipart (direct part upload) ---------------------------------------
|
|
79
|
+
|
|
80
|
+
async createMultipart(key, opts = {}) {
|
|
81
|
+
const uploadId = `mem-${Math.random().toString(36).slice(2)}`;
|
|
82
|
+
this.uploads.set(uploadId, { key, contentType: opts.contentType, parts: new Map() });
|
|
83
|
+
return uploadId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async putPart(key, uploadId, partNumber, body) {
|
|
87
|
+
const up = this.uploads.get(uploadId);
|
|
88
|
+
if (!up) throw TroveError.notFound('Upload');
|
|
89
|
+
const bytes = await toBytes(body);
|
|
90
|
+
up.parts.set(partNumber, bytes);
|
|
91
|
+
return { partNumber, etag: etagOf(bytes) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async completeMultipart(key, uploadId, parts) {
|
|
95
|
+
const up = this.uploads.get(uploadId);
|
|
96
|
+
if (!up) throw TroveError.notFound('Upload');
|
|
97
|
+
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
98
|
+
const chunks = ordered.map((p) => {
|
|
99
|
+
const b = up.parts.get(p.partNumber);
|
|
100
|
+
if (!b) throw TroveError.invalid(`Missing part ${p.partNumber}`);
|
|
101
|
+
return b;
|
|
102
|
+
});
|
|
103
|
+
const bytes = concat(chunks);
|
|
104
|
+
const rec = { bytes, contentType: up.contentType, etag: etagOf(bytes), modifiedAt: Date.now() };
|
|
105
|
+
this.objects.set(key, rec);
|
|
106
|
+
this.uploads.delete(uploadId);
|
|
107
|
+
return { size: bytes.length, etag: rec.etag };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async abortMultipart(key, uploadId) {
|
|
111
|
+
this.uploads.delete(uploadId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// PrefixedStorage — wraps any StorageBackend and namespaces every key under a
|
|
2
|
+
// prefix. This is what lets many collections share one bucket ("bucket + prefix"):
|
|
3
|
+
// each collection gets its own PrefixedStorage over the same underlying S3/FS
|
|
4
|
+
// backend, isolated by key prefix, with zero changes to the backend itself.
|
|
5
|
+
|
|
6
|
+
import { StorageBackend } from './interface.js';
|
|
7
|
+
|
|
8
|
+
export class PrefixedStorage extends StorageBackend {
|
|
9
|
+
/** @param {StorageBackend} inner @param {string} prefix e.g. "team-a/" */
|
|
10
|
+
constructor(inner, prefix) {
|
|
11
|
+
super();
|
|
12
|
+
this.inner = inner;
|
|
13
|
+
this.prefix = prefix ? prefix.replace(/^\/+|\/+$/g, '') + '/' : '';
|
|
14
|
+
}
|
|
15
|
+
#k(key) {
|
|
16
|
+
return this.prefix + key;
|
|
17
|
+
}
|
|
18
|
+
#strip(key) {
|
|
19
|
+
return this.prefix && key.startsWith(this.prefix) ? key.slice(this.prefix.length) : key;
|
|
20
|
+
}
|
|
21
|
+
get capabilities() {
|
|
22
|
+
return this.inner.capabilities;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Free space is a property of the VOLUME, not the prefix, so it passes straight
|
|
26
|
+
* through. Forwarding `capabilities.usage: true` without forwarding this left the
|
|
27
|
+
* client told the backend could answer and then handed `null` every time — so the
|
|
28
|
+
* status bar's meter never appeared and the "running out of storage" issue, whose
|
|
29
|
+
* whole point is to warn before writes start failing, could never be raised for a
|
|
30
|
+
* prefixed collection.
|
|
31
|
+
*/
|
|
32
|
+
usage(opts) {
|
|
33
|
+
return this.inner.usage(opts);
|
|
34
|
+
}
|
|
35
|
+
async list(opts = {}) {
|
|
36
|
+
// The prefix is this wrapper's whole job, so listing has to add it on the way in
|
|
37
|
+
// and strip it on the way out — a caller must never see the wrapper's own prefix
|
|
38
|
+
// and try to store it as a key.
|
|
39
|
+
const res = await this.inner.list({ ...opts, prefix: this.#k(opts.prefix || '') });
|
|
40
|
+
return { ...res, objects: res.objects.map((o) => ({ ...o, key: this.#strip(o.key) })) };
|
|
41
|
+
}
|
|
42
|
+
put(key, body, opts) {
|
|
43
|
+
return this.inner.put(this.#k(key), body, opts);
|
|
44
|
+
}
|
|
45
|
+
get(key, opts) {
|
|
46
|
+
return this.inner.get(this.#k(key), opts);
|
|
47
|
+
}
|
|
48
|
+
head(key) {
|
|
49
|
+
return this.inner.head(this.#k(key));
|
|
50
|
+
}
|
|
51
|
+
delete(key, opts) {
|
|
52
|
+
return this.inner.delete(this.#k(key), opts);
|
|
53
|
+
}
|
|
54
|
+
presignGet(key, opts) {
|
|
55
|
+
return this.inner.presignGet(this.#k(key), opts);
|
|
56
|
+
}
|
|
57
|
+
presignPut(key, opts) {
|
|
58
|
+
return this.inner.presignPut(this.#k(key), opts);
|
|
59
|
+
}
|
|
60
|
+
createMultipart(key, opts) {
|
|
61
|
+
return this.inner.createMultipart(this.#k(key), opts);
|
|
62
|
+
}
|
|
63
|
+
presignPart(key, uploadId, partNumber, opts) {
|
|
64
|
+
return this.inner.presignPart(this.#k(key), uploadId, partNumber, opts);
|
|
65
|
+
}
|
|
66
|
+
putPart(key, uploadId, partNumber, body, opts) {
|
|
67
|
+
return this.inner.putPart(this.#k(key), uploadId, partNumber, body, opts);
|
|
68
|
+
}
|
|
69
|
+
completeMultipart(key, uploadId, parts, opts) {
|
|
70
|
+
return this.inner.completeMultipart(this.#k(key), uploadId, parts, opts);
|
|
71
|
+
}
|
|
72
|
+
abortMultipart(key, uploadId, opts) {
|
|
73
|
+
return this.inner.abortMultipart(this.#k(key), uploadId, opts);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// S3-compatible storage backend (AWS S3, Cloudflare R2, MinIO, Backblaze B2…).
|
|
2
|
+
// Uses fetch + our Web-Crypto SigV4 signer, so it works on any modern runtime
|
|
3
|
+
// including Cloudflare Workers. The headline capability is presigning: clients
|
|
4
|
+
// upload and download large objects straight to/from S3, never proxied through
|
|
5
|
+
// the Trove server.
|
|
6
|
+
//
|
|
7
|
+
// Path-style vs virtual-host addressing is configurable (`forcePathStyle`);
|
|
8
|
+
// MinIO/R2-with-custom-domain want path style, real AWS wants virtual host.
|
|
9
|
+
|
|
10
|
+
import { StorageBackend, toBytes } from './interface.js';
|
|
11
|
+
import { presignUrl, signRequest, sha256Hex, uriEncode } from './s3sigv4.js';
|
|
12
|
+
import { TroveError, wrapError } from '../errors.js';
|
|
13
|
+
import { withRetry } from '../retry.js';
|
|
14
|
+
|
|
15
|
+
export class S3Storage extends StorageBackend {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} cfg
|
|
18
|
+
* @param {string} cfg.bucket
|
|
19
|
+
* @param {string} cfg.region
|
|
20
|
+
* @param {string} cfg.accessKeyId
|
|
21
|
+
* @param {string} cfg.secretAccessKey
|
|
22
|
+
* @param {string} [cfg.sessionToken]
|
|
23
|
+
* @param {string} [cfg.endpoint] e.g. https://<acct>.r2.cloudflarestorage.com (defaults to AWS)
|
|
24
|
+
* @param {boolean} [cfg.forcePathStyle]
|
|
25
|
+
* @param {number} [cfg.presignExpiry] default seconds for signed URLs
|
|
26
|
+
*/
|
|
27
|
+
constructor(cfg) {
|
|
28
|
+
super();
|
|
29
|
+
for (const k of ['bucket', 'region', 'accessKeyId', 'secretAccessKey']) {
|
|
30
|
+
if (!cfg?.[k]) throw TroveError.invalid(`S3Storage requires "${k}"`);
|
|
31
|
+
}
|
|
32
|
+
this.cfg = cfg;
|
|
33
|
+
this.creds = {
|
|
34
|
+
accessKeyId: cfg.accessKeyId,
|
|
35
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
36
|
+
sessionToken: cfg.sessionToken,
|
|
37
|
+
region: cfg.region,
|
|
38
|
+
service: 's3',
|
|
39
|
+
};
|
|
40
|
+
this.presignExpiry = cfg.presignExpiry ?? 900;
|
|
41
|
+
const host = cfg.endpoint
|
|
42
|
+
? cfg.endpoint.replace(/\/$/, '')
|
|
43
|
+
: `https://s3.${cfg.region}.amazonaws.com`;
|
|
44
|
+
this.forcePathStyle = cfg.forcePathStyle ?? !!cfg.endpoint;
|
|
45
|
+
this.base = this.forcePathStyle ? `${host}/${cfg.bucket}` : insertBucket(host, cfg.bucket);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get capabilities() {
|
|
49
|
+
return { presignDownload: true, presignUpload: true, multipart: true, range: true, list: true, usage: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The object's URL, encoded the way SigV4 will canonicalize it.
|
|
54
|
+
*
|
|
55
|
+
* `uriEncode`, not `encodeURIComponent`/`URLSearchParams`: the signature is computed
|
|
56
|
+
* over this exact string, so anything that encodes differently here than the signer
|
|
57
|
+
* does there produces a URL we send one way and sign another — a 403 from S3 for any
|
|
58
|
+
* key or parameter containing a space, a `+`, a `!`, or a non-ASCII character.
|
|
59
|
+
*/
|
|
60
|
+
#url(key, query) {
|
|
61
|
+
const u = new URL(`${this.base}/${uriEncode(key, false)}`);
|
|
62
|
+
if (query) {
|
|
63
|
+
u.search = Object.entries(query)
|
|
64
|
+
.map(([k, v]) => `${uriEncode(k)}=${uriEncode(v)}`)
|
|
65
|
+
.join('&');
|
|
66
|
+
}
|
|
67
|
+
return u.toString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async #send(method, key, { query, headers, body, signal } = {}) {
|
|
71
|
+
return withRetry(
|
|
72
|
+
async () => {
|
|
73
|
+
const url = this.#url(key, query);
|
|
74
|
+
const payloadHash = body ? await sha256Hex(body) : await sha256Hex('');
|
|
75
|
+
const signed = await signRequest(this.creds, { method, url, headers, body, payloadHash });
|
|
76
|
+
let res;
|
|
77
|
+
try {
|
|
78
|
+
res = await fetch(url, { method, headers: signed, body, signal });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw wrapError(err);
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok && res.status >= 500) {
|
|
83
|
+
throw TroveError.transient(`S3 ${method} ${res.status}`, { details: { status: res.status } });
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 429 || res.status === 503) {
|
|
86
|
+
throw TroveError.transient('S3 throttled', { details: { status: res.status } });
|
|
87
|
+
}
|
|
88
|
+
return res;
|
|
89
|
+
},
|
|
90
|
+
{ signal },
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async put(key, body, opts = {}) {
|
|
95
|
+
const bytes = await toBytes(body);
|
|
96
|
+
const res = await this.#send('PUT', key, {
|
|
97
|
+
body: bytes,
|
|
98
|
+
headers: opts.contentType ? { 'content-type': opts.contentType } : {},
|
|
99
|
+
signal: opts.signal,
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) throw await s3Error(res, 'put');
|
|
102
|
+
opts.onProgress?.(bytes.length);
|
|
103
|
+
return { size: bytes.length, contentType: opts.contentType, etag: res.headers.get('etag') || undefined };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async head(key) {
|
|
107
|
+
const res = await this.#send('HEAD', key);
|
|
108
|
+
if (res.status === 404) throw TroveError.notFound('Object');
|
|
109
|
+
if (!res.ok) throw await s3Error(res, 'head');
|
|
110
|
+
return {
|
|
111
|
+
size: Number(res.headers.get('content-length') || 0),
|
|
112
|
+
contentType: res.headers.get('content-type') || undefined,
|
|
113
|
+
etag: res.headers.get('etag') || undefined,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async get(key, opts = {}) {
|
|
118
|
+
const headers = {};
|
|
119
|
+
if (opts.range) {
|
|
120
|
+
// S3 understands the suffix form natively, so pass it through rather than
|
|
121
|
+
// resolving it here — we'd need a HEAD to learn the total first. Except `-0`,
|
|
122
|
+
// which S3 answers with the entire object under a 200 where every other backend
|
|
123
|
+
// calls it unsatisfiable.
|
|
124
|
+
if (opts.range.suffix != null) {
|
|
125
|
+
if (!(opts.range.suffix > 0)) throw TroveError.badRange('Range not satisfiable');
|
|
126
|
+
headers.range = `bytes=-${opts.range.suffix}`;
|
|
127
|
+
} else headers.range = `bytes=${opts.range.start ?? 0}-${opts.range.end != null ? opts.range.end : ''}`;
|
|
128
|
+
}
|
|
129
|
+
const res = await this.#send('GET', key, { headers, signal: opts.signal });
|
|
130
|
+
if (res.status === 404) throw TroveError.notFound('Object');
|
|
131
|
+
// 416 is a real answer, not an internal error: a media player seeking past the end
|
|
132
|
+
// of a file got "Internal error" on S3 and a 400 everywhere else.
|
|
133
|
+
if (res.status === 416) throw TroveError.badRange('Range not satisfiable');
|
|
134
|
+
if (!res.ok && res.status !== 206) throw await s3Error(res, 'get');
|
|
135
|
+
let range;
|
|
136
|
+
const cr = res.headers.get('content-range'); // bytes start-end/total
|
|
137
|
+
if (cr) {
|
|
138
|
+
const m = /bytes (\d+)-(\d+)\/(\d+)/.exec(cr);
|
|
139
|
+
if (m) range = { start: +m[1], end: +m[2], total: +m[3] };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
stream: res.body,
|
|
143
|
+
size: Number(res.headers.get('content-length') || 0),
|
|
144
|
+
contentType: res.headers.get('content-type') || undefined,
|
|
145
|
+
etag: res.headers.get('etag') || undefined,
|
|
146
|
+
range,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async delete(key, opts = {}) {
|
|
151
|
+
const res = await this.#send('DELETE', key, { signal: opts.signal });
|
|
152
|
+
if (!res.ok && res.status !== 404) throw await s3Error(res, 'delete');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* ListObjectsV2 — how a scan sees objects that reached the bucket without us.
|
|
157
|
+
*
|
|
158
|
+
* The bucket is shared infrastructure: another tool, another person, or a lifecycle
|
|
159
|
+
* rule can add and remove objects, and this is the only way to find out. Paged on S3's
|
|
160
|
+
* own continuation token rather than a key offset, because that is what the service
|
|
161
|
+
* guarantees is stable across a page boundary.
|
|
162
|
+
*/
|
|
163
|
+
async list({ prefix = '', cursor = null, limit = 1000, signal } = {}) {
|
|
164
|
+
const query = { 'list-type': '2', 'max-keys': String(Math.min(limit, 1000)) };
|
|
165
|
+
if (prefix) query.prefix = prefix;
|
|
166
|
+
if (cursor) query['continuation-token'] = cursor;
|
|
167
|
+
// Bucket-level operation: the key is empty, the bucket is in the base URL.
|
|
168
|
+
const res = await this.#send('GET', '', { query, signal });
|
|
169
|
+
if (!res.ok) throw await s3Error(res, 'list');
|
|
170
|
+
const xml = await res.text();
|
|
171
|
+
const objects = [];
|
|
172
|
+
for (const entry of tagAll(xml, 'Contents')) {
|
|
173
|
+
const key = tag(entry, 'Key');
|
|
174
|
+
if (key == null) continue;
|
|
175
|
+
const modified = Date.parse(tag(entry, 'LastModified') || '');
|
|
176
|
+
objects.push({
|
|
177
|
+
key: decodeXml(key),
|
|
178
|
+
size: Number(tag(entry, 'Size') || 0),
|
|
179
|
+
// decodeXml here too. S3 wraps an ETag in quotes and then XML-escapes them, so
|
|
180
|
+
// the raw text is `"d16f…"` while `head()` reports `"d16f…"`. The
|
|
181
|
+
// scanner's normalizeEtag strips a real quote but not the entity, so the first
|
|
182
|
+
// scan of any S3 collection called every object changed, re-read and re-embedded
|
|
183
|
+
// the whole drive, and persisted the escaped form — after which every later
|
|
184
|
+
// upload flipped it back and triggered the same spurious refresh again.
|
|
185
|
+
etag: decodeXml(tag(entry, 'ETag') || '') || undefined,
|
|
186
|
+
modifiedAt: Number.isNaN(modified) ? null : modified,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Truncated is authoritative: an empty page can still have more behind it.
|
|
190
|
+
const truncated = tag(xml, 'IsTruncated') === 'true';
|
|
191
|
+
return { objects, nextCursor: truncated ? tag(xml, 'NextContinuationToken') : null };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- presigning ------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
async presignGet(key, opts = {}) {
|
|
197
|
+
const query = {};
|
|
198
|
+
if (opts.downloadName) {
|
|
199
|
+
query['response-content-disposition'] = `attachment; filename="${sanitizeFilename(opts.downloadName)}"`;
|
|
200
|
+
}
|
|
201
|
+
if (opts.responseContentType) query['response-content-type'] = opts.responseContentType;
|
|
202
|
+
return presignUrl(this.creds, {
|
|
203
|
+
method: 'GET',
|
|
204
|
+
url: this.#url(key),
|
|
205
|
+
expiresIn: opts.expiresIn ?? this.presignExpiry,
|
|
206
|
+
query,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async presignPut(key, opts = {}) {
|
|
211
|
+
return presignUrl(this.creds, {
|
|
212
|
+
method: 'PUT',
|
|
213
|
+
url: this.#url(key),
|
|
214
|
+
expiresIn: opts.expiresIn ?? this.presignExpiry,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --- multipart -------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
async createMultipart(key, opts = {}) {
|
|
221
|
+
const res = await this.#send('POST', key, {
|
|
222
|
+
query: { uploads: '' },
|
|
223
|
+
headers: opts.contentType ? { 'content-type': opts.contentType } : {},
|
|
224
|
+
});
|
|
225
|
+
if (!res.ok) throw await s3Error(res, 'createMultipart');
|
|
226
|
+
const xml = await res.text();
|
|
227
|
+
const id = tag(xml, 'UploadId');
|
|
228
|
+
if (!id) throw TroveError.internal('S3 did not return an UploadId');
|
|
229
|
+
return id;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async presignPart(key, uploadId, partNumber, opts = {}) {
|
|
233
|
+
return presignUrl(this.creds, {
|
|
234
|
+
method: 'PUT',
|
|
235
|
+
url: this.#url(key),
|
|
236
|
+
expiresIn: opts.expiresIn ?? this.presignExpiry,
|
|
237
|
+
query: { partNumber: String(partNumber), uploadId },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async putPart(key, uploadId, partNumber, body, opts = {}) {
|
|
242
|
+
const res = await this.#send('PUT', key, {
|
|
243
|
+
query: { partNumber: String(partNumber), uploadId },
|
|
244
|
+
body: await toBytes(body),
|
|
245
|
+
signal: opts.signal,
|
|
246
|
+
});
|
|
247
|
+
if (!res.ok) throw await s3Error(res, 'putPart');
|
|
248
|
+
const etag = res.headers.get('etag');
|
|
249
|
+
return { partNumber, etag };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async completeMultipart(key, uploadId, parts) {
|
|
253
|
+
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
254
|
+
const body =
|
|
255
|
+
`<CompleteMultipartUpload>` +
|
|
256
|
+
ordered
|
|
257
|
+
.map((p) => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${p.etag}</ETag></Part>`)
|
|
258
|
+
.join('') +
|
|
259
|
+
`</CompleteMultipartUpload>`;
|
|
260
|
+
const res = await this.#send('POST', key, { query: { uploadId }, body, headers: { 'content-type': 'application/xml' } });
|
|
261
|
+
if (!res.ok) throw await s3Error(res, 'completeMultipart');
|
|
262
|
+
const xml = await res.text();
|
|
263
|
+
// S3 can return a 200 with an error body — check for that.
|
|
264
|
+
if (xml.includes('<Error>')) throw TroveError.transient('S3 completeMultipart failed', { details: { xml } });
|
|
265
|
+
return { etag: tag(xml, 'ETag') || undefined };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async abortMultipart(key, uploadId) {
|
|
269
|
+
const res = await this.#send('DELETE', key, { query: { uploadId } });
|
|
270
|
+
if (!res.ok && res.status !== 404) throw await s3Error(res, 'abortMultipart');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function insertBucket(host, bucket) {
|
|
275
|
+
const u = new URL(host);
|
|
276
|
+
u.host = `${bucket}.${u.host}`;
|
|
277
|
+
return u.toString().replace(/\/$/, '');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function tag(xml, name) {
|
|
281
|
+
const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(xml);
|
|
282
|
+
return m ? m[1] : null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Every occurrence of a repeated element (ListObjectsV2 returns one <Contents> per object). */
|
|
286
|
+
function tagAll(xml, name) {
|
|
287
|
+
const out = [];
|
|
288
|
+
const re = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`, 'g');
|
|
289
|
+
let m;
|
|
290
|
+
while ((m = re.exec(xml))) out.push(m[1]);
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** S3 XML-escapes keys; a key with & or < in it comes back encoded. */
|
|
295
|
+
function decodeXml(s) {
|
|
296
|
+
return String(s)
|
|
297
|
+
.replace(/</g, '<').replace(/>/g, '>')
|
|
298
|
+
.replace(/"/g, '"').replace(/'/g, "'")
|
|
299
|
+
.replace(/&/g, '&');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function sanitizeFilename(name) {
|
|
303
|
+
return String(name).replace(/[\r\n"\\]/g, '_');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function s3Error(res, op) {
|
|
307
|
+
let body = '';
|
|
308
|
+
try {
|
|
309
|
+
body = await res.text();
|
|
310
|
+
} catch { /* ignore */ }
|
|
311
|
+
const code = tag(body, 'Code');
|
|
312
|
+
const msg = tag(body, 'Message') || res.statusText;
|
|
313
|
+
if (res.status === 403) return new TroveError('forbidden', `S3 ${op}: ${msg}`, { details: { code } });
|
|
314
|
+
if (res.status === 404) return TroveError.notFound('Object');
|
|
315
|
+
return TroveError.internal(`S3 ${op} failed (${res.status}): ${msg}`, { details: { code } });
|
|
316
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// A compact AWS Signature V4 implementation built on Web Crypto (crypto.subtle),
|
|
2
|
+
// so it runs unchanged on Node ≥20, Bun, Deno, and Cloudflare Workers — no
|
|
3
|
+
// aws-sdk, no Node crypto. Supports both signed requests (Authorization header)
|
|
4
|
+
// and presigned URLs (query-string signing), which is what lets clients GET/PUT
|
|
5
|
+
// large objects straight from S3 without proxying through our server.
|
|
6
|
+
//
|
|
7
|
+
// Scope: exactly what an S3-compatible object store needs (S3, MinIO, R2, B2…).
|
|
8
|
+
|
|
9
|
+
const enc = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
async function sha256Hex(data) {
|
|
12
|
+
const buf = await crypto.subtle.digest('SHA-256', typeof data === 'string' ? enc.encode(data) : data);
|
|
13
|
+
return hex(buf);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function hmac(key, data) {
|
|
17
|
+
const k = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
18
|
+
return new Uint8Array(await crypto.subtle.sign('HMAC', k, enc.encode(data)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hex(buf) {
|
|
22
|
+
const b = new Uint8Array(buf);
|
|
23
|
+
let s = '';
|
|
24
|
+
for (let i = 0; i < b.length; i++) s += b[i].toString(16).padStart(2, '0');
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RFC 3986 encoding — the `UriEncode()` SigV4 specifies. Deliberately stricter than
|
|
30
|
+
* `encodeURIComponent`, which leaves `!'()*` literal; S3 percent-encodes those when it
|
|
31
|
+
* recomputes the canonical request, so a URL built with encodeURIComponent gets signed
|
|
32
|
+
* one way and verified another.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} str
|
|
35
|
+
* @param {boolean} [encodeSlash] false for a path (slashes are separators, not data)
|
|
36
|
+
*/
|
|
37
|
+
export function uriEncode(str, encodeSlash = true) {
|
|
38
|
+
let out = '';
|
|
39
|
+
for (const ch of String(str)) {
|
|
40
|
+
if (/[A-Za-z0-9\-._~]/.test(ch)) out += ch;
|
|
41
|
+
else if (ch === '/' && !encodeSlash) out += ch;
|
|
42
|
+
else {
|
|
43
|
+
for (const byte of enc.encode(ch)) out += '%' + byte.toString(16).toUpperCase().padStart(2, '0');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function amzDate(d = new Date()) {
|
|
50
|
+
const iso = d.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
51
|
+
return { amz: iso, date: iso.slice(0, 8) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function signingKey(secret, date, region, service) {
|
|
55
|
+
const kDate = await hmac(enc.encode('AWS4' + secret), date);
|
|
56
|
+
const kRegion = await hmac(kDate, region);
|
|
57
|
+
const kService = await hmac(kRegion, service);
|
|
58
|
+
return hmac(kService, 'aws4_request');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} S3Creds
|
|
63
|
+
* @property {string} accessKeyId
|
|
64
|
+
* @property {string} secretAccessKey
|
|
65
|
+
* @property {string} region
|
|
66
|
+
* @property {string} [sessionToken]
|
|
67
|
+
* @property {string} service usually 's3'
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build a presigned URL.
|
|
72
|
+
* @param {S3Creds} creds
|
|
73
|
+
* @param {object} o
|
|
74
|
+
* @param {string} o.method GET|PUT|POST|DELETE
|
|
75
|
+
* @param {string} o.url full endpoint URL for the object (incl. host + path)
|
|
76
|
+
* @param {number} [o.expiresIn] seconds (default 900, max 604800)
|
|
77
|
+
* @param {Record<string,string>} [o.query] extra query params (e.g. partNumber, uploadId, response-content-disposition)
|
|
78
|
+
* @param {Record<string,string>} [o.signedHeaders] headers to bind (host is always bound)
|
|
79
|
+
*/
|
|
80
|
+
export async function presignUrl(creds, o) {
|
|
81
|
+
const url = new URL(o.url);
|
|
82
|
+
const { amz, date } = amzDate();
|
|
83
|
+
const expiresIn = Math.min(o.expiresIn ?? 900, 604800);
|
|
84
|
+
const scope = `${date}/${creds.region}/${creds.service}/aws4_request`;
|
|
85
|
+
|
|
86
|
+
const headers = { host: url.host, ...(o.signedHeaders || {}) };
|
|
87
|
+
const signedHeaderNames = Object.keys(headers)
|
|
88
|
+
.map((h) => h.toLowerCase())
|
|
89
|
+
.sort();
|
|
90
|
+
const canonicalHeaders = signedHeaderNames.map((h) => `${h}:${String(headers[h]).trim()}\n`).join('');
|
|
91
|
+
const signedHeaders = signedHeaderNames.join(';');
|
|
92
|
+
|
|
93
|
+
const q = new URLSearchParams(url.search);
|
|
94
|
+
for (const [k, v] of Object.entries(o.query || {})) q.set(k, v);
|
|
95
|
+
q.set('X-Amz-Algorithm', 'AWS4-HMAC-SHA256');
|
|
96
|
+
q.set('X-Amz-Credential', `${creds.accessKeyId}/${scope}`);
|
|
97
|
+
q.set('X-Amz-Date', amz);
|
|
98
|
+
q.set('X-Amz-Expires', String(expiresIn));
|
|
99
|
+
if (creds.sessionToken) q.set('X-Amz-Security-Token', creds.sessionToken);
|
|
100
|
+
q.set('X-Amz-SignedHeaders', signedHeaders);
|
|
101
|
+
|
|
102
|
+
// Canonical query string: sorted, RFC3986-encoded.
|
|
103
|
+
const canonicalQuery = [...q.entries()]
|
|
104
|
+
.map(([k, v]) => [uriEncode(k), uriEncode(v)])
|
|
105
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1))
|
|
106
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
107
|
+
.join('&');
|
|
108
|
+
|
|
109
|
+
const canonicalRequest = [
|
|
110
|
+
o.method,
|
|
111
|
+
// The path VERBATIM. `url.pathname` is already percent-encoded, and S3 (alone among
|
|
112
|
+
// AWS services) single-encodes the canonical URI — re-encoding here would turn the
|
|
113
|
+
// %20 we send into the %2520 we sign, and every key with a space in it would come
|
|
114
|
+
// back 403 SignatureDoesNotMatch. Callers must therefore hand us a URL whose path
|
|
115
|
+
// was built with `uriEncode`, not `encodeURIComponent`.
|
|
116
|
+
url.pathname,
|
|
117
|
+
canonicalQuery,
|
|
118
|
+
canonicalHeaders,
|
|
119
|
+
signedHeaders,
|
|
120
|
+
'UNSIGNED-PAYLOAD',
|
|
121
|
+
].join('\n');
|
|
122
|
+
|
|
123
|
+
const stringToSign = [
|
|
124
|
+
'AWS4-HMAC-SHA256',
|
|
125
|
+
amz,
|
|
126
|
+
scope,
|
|
127
|
+
await sha256Hex(canonicalRequest),
|
|
128
|
+
].join('\n');
|
|
129
|
+
|
|
130
|
+
const key = await signingKey(creds.secretAccessKey, date, creds.region, creds.service);
|
|
131
|
+
const signature = hex(await hmac(key, stringToSign));
|
|
132
|
+
|
|
133
|
+
url.search = canonicalQuery + `&X-Amz-Signature=${signature}`;
|
|
134
|
+
return url.toString();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Sign a request with an Authorization header (for server-side S3 calls).
|
|
139
|
+
* @returns {Promise<Record<string,string>>} headers to send.
|
|
140
|
+
*/
|
|
141
|
+
export async function signRequest(creds, o) {
|
|
142
|
+
const url = new URL(o.url);
|
|
143
|
+
const { amz, date } = amzDate();
|
|
144
|
+
const scope = `${date}/${creds.region}/${creds.service}/aws4_request`;
|
|
145
|
+
const payloadHash = o.payloadHash || (await sha256Hex(o.body ?? ''));
|
|
146
|
+
|
|
147
|
+
const headers = {
|
|
148
|
+
host: url.host,
|
|
149
|
+
'x-amz-content-sha256': payloadHash,
|
|
150
|
+
'x-amz-date': amz,
|
|
151
|
+
...(creds.sessionToken ? { 'x-amz-security-token': creds.sessionToken } : {}),
|
|
152
|
+
...(o.headers || {}),
|
|
153
|
+
};
|
|
154
|
+
const names = Object.keys(headers)
|
|
155
|
+
.map((h) => h.toLowerCase())
|
|
156
|
+
.sort();
|
|
157
|
+
const canonicalHeaders = names.map((h) => `${h}:${String(headers[h]).trim()}\n`).join('');
|
|
158
|
+
const signedHeaders = names.join(';');
|
|
159
|
+
|
|
160
|
+
const canonicalQuery = [...url.searchParams.entries()]
|
|
161
|
+
.map(([k, v]) => [uriEncode(k), uriEncode(v)])
|
|
162
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
163
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
164
|
+
.join('&');
|
|
165
|
+
|
|
166
|
+
const canonicalRequest = [
|
|
167
|
+
o.method,
|
|
168
|
+
url.pathname, // verbatim — see the note in presignUrl
|
|
169
|
+
canonicalQuery,
|
|
170
|
+
canonicalHeaders,
|
|
171
|
+
signedHeaders,
|
|
172
|
+
payloadHash,
|
|
173
|
+
].join('\n');
|
|
174
|
+
|
|
175
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amz, scope, await sha256Hex(canonicalRequest)].join('\n');
|
|
176
|
+
const key = await signingKey(creds.secretAccessKey, date, creds.region, creds.service);
|
|
177
|
+
const signature = hex(await hmac(key, stringToSign));
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...headers,
|
|
181
|
+
Authorization: `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export { sha256Hex };
|