@prisma/composer-prisma-cloud 0.1.0-dev.1
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/LICENSE +201 -0
- package/dist/control.d.mts +53 -0
- package/dist/control.mjs +2031 -0
- package/dist/control.mjs.map +1 -0
- package/dist/cron/index.d.mts +99 -0
- package/dist/cron/index.mjs +392 -0
- package/dist/cron/index.mjs.map +1 -0
- package/dist/cron/scheduler-entrypoint.mjs +7769 -0
- package/dist/cron/scheduler-entrypoint.mjs.map +1 -0
- package/dist/cron/scheduler-service.mjs +318 -0
- package/dist/cron/scheduler-service.mjs.map +1 -0
- package/dist/index.d.mts +205 -0
- package/dist/index.mjs +182 -0
- package/dist/index.mjs.map +1 -0
- package/dist/param-DB0B8m15-IvzNq9BM.mjs +92 -0
- package/dist/param-DB0B8m15-IvzNq9BM.mjs.map +1 -0
- package/dist/prisma-next-COrwlg3N.mjs +176 -0
- package/dist/prisma-next-COrwlg3N.mjs.map +1 -0
- package/dist/prisma-next.d.mts +71 -0
- package/dist/prisma-next.mjs +2 -0
- package/dist/serializer-DAEWRfnm-D3GW9dOZ.mjs +235 -0
- package/dist/serializer-DAEWRfnm-D3GW9dOZ.mjs.map +1 -0
- package/dist/storage/index.d.mts +55 -0
- package/dist/storage/index.mjs +411 -0
- package/dist/storage/index.mjs.map +1 -0
- package/dist/storage/storage-entrypoint.mjs +1173 -0
- package/dist/storage/storage-entrypoint.mjs.map +1 -0
- package/dist/storage/storage-service.mjs +377 -0
- package/dist/storage/storage-service.mjs.map +1 -0
- package/dist/storage/testing.d.mts +85 -0
- package/dist/storage/testing.mjs +531 -0
- package/dist/storage/testing.mjs.map +1 -0
- package/dist/streams/index.d.mts +47 -0
- package/dist/streams/index.mjs +450 -0
- package/dist/streams/index.mjs.map +1 -0
- package/dist/streams/streams-entrypoint.mjs +40575 -0
- package/dist/streams/streams-entrypoint.mjs.map +1 -0
- package/dist/streams/streams-service.mjs +424 -0
- package/dist/streams/streams-service.mjs.map +1 -0
- package/dist/streams/testing.d.mts +33 -0
- package/dist/streams/testing.mjs +31335 -0
- package/dist/streams/testing.mjs.map +1 -0
- package/dist/testing.d.mts +25 -0
- package/dist/testing.mjs +32 -0
- package/dist/testing.mjs.map +1 -0
- package/package.json +74 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//#region ../../1-prisma-cloud/2-shared-modules/storage/dist/testing.d.mts
|
|
2
|
+
//#region src/store.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The minimal object store the protocol handler drives — the seam between the
|
|
5
|
+
* wire protocol (D2) and its backing (the Postgres bytea store, D3). Buckets
|
|
6
|
+
* are namespaces: any bucket name is accepted and simply scopes keys. The
|
|
7
|
+
* store owns the ETag (quoted SHA-256 hex of the object bytes).
|
|
8
|
+
*/
|
|
9
|
+
interface PutResult {
|
|
10
|
+
readonly etag: string;
|
|
11
|
+
}
|
|
12
|
+
interface GetRange {
|
|
13
|
+
readonly start: number;
|
|
14
|
+
/** Inclusive end; omitted means "to the end of the object". */
|
|
15
|
+
readonly end?: number;
|
|
16
|
+
}
|
|
17
|
+
interface GetResult {
|
|
18
|
+
/** The requested slice — the whole object when no range was given. */
|
|
19
|
+
readonly bytes: Uint8Array;
|
|
20
|
+
readonly etag: string;
|
|
21
|
+
readonly contentType: string;
|
|
22
|
+
/** TOTAL object size, for `Content-Range` — not the slice length. */
|
|
23
|
+
readonly size: number;
|
|
24
|
+
}
|
|
25
|
+
interface HeadResult {
|
|
26
|
+
readonly etag: string;
|
|
27
|
+
readonly size: number;
|
|
28
|
+
readonly contentType: string;
|
|
29
|
+
}
|
|
30
|
+
interface ListOptions {
|
|
31
|
+
readonly prefix?: string;
|
|
32
|
+
readonly continuationToken?: string;
|
|
33
|
+
readonly maxKeys?: number;
|
|
34
|
+
}
|
|
35
|
+
interface ListResult {
|
|
36
|
+
readonly keys: readonly string[];
|
|
37
|
+
readonly nextContinuationToken?: string;
|
|
38
|
+
readonly isTruncated: boolean;
|
|
39
|
+
}
|
|
40
|
+
interface ObjectStore {
|
|
41
|
+
put(bucket: string, key: string, bytes: Uint8Array, opts?: {
|
|
42
|
+
contentType?: string;
|
|
43
|
+
}): Promise<PutResult>;
|
|
44
|
+
/** `null` when the key is missing. */
|
|
45
|
+
get(bucket: string, key: string, opts?: {
|
|
46
|
+
range?: GetRange;
|
|
47
|
+
}): Promise<GetResult | null>;
|
|
48
|
+
/** `null` when the key is missing. */
|
|
49
|
+
head(bucket: string, key: string): Promise<HeadResult | null>;
|
|
50
|
+
/** Idempotent — deleting a missing key is not an error. */
|
|
51
|
+
delete(bucket: string, key: string): Promise<void>;
|
|
52
|
+
list(bucket: string, opts?: ListOptions): Promise<ListResult>;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/pg-store.d.ts
|
|
56
|
+
/**
|
|
57
|
+
* Connect (FT-5219 posture: `max: 1`, short `idleTimeout`), apply the schema
|
|
58
|
+
* idempotently behind the cold-start retry, and return the store.
|
|
59
|
+
*/
|
|
60
|
+
declare function createPgStore(url: string): Promise<ObjectStore>;
|
|
61
|
+
//#endregion
|
|
62
|
+
//#region src/sigv4.d.ts
|
|
63
|
+
interface Credentials {
|
|
64
|
+
readonly accessKeyId: string;
|
|
65
|
+
readonly secretAccessKey: string;
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/storage-server.d.ts
|
|
69
|
+
interface StorageServer {
|
|
70
|
+
/** The externally reachable base URL of the running server. */
|
|
71
|
+
readonly url: string;
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
interface StorageServerOptions {
|
|
75
|
+
readonly store: ObjectStore;
|
|
76
|
+
readonly credentials: Credentials;
|
|
77
|
+
/** The module's canonical bucket — surfaced to consumers; the wire namespaces by the path bucket. */
|
|
78
|
+
readonly bucket: string;
|
|
79
|
+
readonly port: number;
|
|
80
|
+
readonly hostname?: string;
|
|
81
|
+
}
|
|
82
|
+
declare function startStorageServer(opts: StorageServerOptions): StorageServer;
|
|
83
|
+
//#endregion
|
|
84
|
+
export { type StorageServer, type StorageServerOptions, createPgStore, startStorageServer };
|
|
85
|
+
//# sourceMappingURL=testing.d.mts.map
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
//#region ../../1-prisma-cloud/2-shared-modules/storage/dist/testing.mjs
|
|
4
|
+
/** Connection resilience helpers for Prisma Postgres cold-starts (FT-5226); no heavy imports (no `effect`/`alchemy`/`pg`), so the deploy lowerings, the pnPostgres runtime client, and bun-runnable services (the storage store, via the pure `@internal/prisma-cloud/connection` subpath) all share one implementation. */
|
|
5
|
+
/** Network-level socket failures node-postgres surfaces as `err.code`. */
|
|
6
|
+
const TRANSIENT_CODES = /* @__PURE__ */ new Set([
|
|
7
|
+
"ECONNREFUSED",
|
|
8
|
+
"ECONNRESET",
|
|
9
|
+
"ETIMEDOUT",
|
|
10
|
+
"EPIPE",
|
|
11
|
+
"ENOTFOUND",
|
|
12
|
+
"EAI_AGAIN"
|
|
13
|
+
]);
|
|
14
|
+
/** Connection-establishment failure messages (no useful `err.code`). */
|
|
15
|
+
const TRANSIENT_MESSAGE_FRAGMENTS = [
|
|
16
|
+
"upstream database",
|
|
17
|
+
"connection terminated",
|
|
18
|
+
"connection refused",
|
|
19
|
+
"terminating connection",
|
|
20
|
+
"server closed the connection",
|
|
21
|
+
"connection timeout",
|
|
22
|
+
"timeout expired"
|
|
23
|
+
];
|
|
24
|
+
/** Whether an error is a transient connection failure worth retrying, as opposed to a real query error that must surface at once. */
|
|
25
|
+
function isTransientConnectionError(error) {
|
|
26
|
+
if (typeof error !== "object" || error === null) return false;
|
|
27
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : void 0;
|
|
28
|
+
if (code !== void 0 && TRANSIENT_CODES.has(code)) return true;
|
|
29
|
+
const message = "message" in error && typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
30
|
+
return TRANSIENT_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Retries an operation past a transient connection failure, bounded (default
|
|
34
|
+
* ~1 min). `shouldRetry` decides what's transient — defaults to retrying
|
|
35
|
+
* everything; the runtime client passes {@link isTransientConnectionError}.
|
|
36
|
+
*/
|
|
37
|
+
async function withConnectionRetry(operation, opts = {}) {
|
|
38
|
+
const attempts = opts.attempts ?? 12;
|
|
39
|
+
const delayMs = opts.delayMs ?? 5e3;
|
|
40
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
41
|
+
const shouldRetry = opts.shouldRetry ?? (() => true);
|
|
42
|
+
let lastError;
|
|
43
|
+
for (let attempt = 1; attempt <= attempts; attempt++) try {
|
|
44
|
+
return await operation();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!shouldRetry(error)) throw error;
|
|
47
|
+
lastError = error;
|
|
48
|
+
if (attempt < attempts) await sleep(delayMs);
|
|
49
|
+
}
|
|
50
|
+
throw lastError;
|
|
51
|
+
}
|
|
52
|
+
/** Retries acquiring a connection past a transient cold-start; {@link withConnectionRetry} with {@link isTransientConnectionError} fixed as the predicate. */
|
|
53
|
+
function retryTransientConnect(acquire, opts = {}) {
|
|
54
|
+
return withConnectionRetry(acquire, {
|
|
55
|
+
...opts,
|
|
56
|
+
shouldRetry: isTransientConnectionError
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The `ObjectStore` over Postgres `bytea` (spec § 3): one `objects` table,
|
|
61
|
+
* single-row-per-object. Ranged reads use SQL `substring` so a range request
|
|
62
|
+
* never detoasts the whole object. The schema is applied idempotently at init
|
|
63
|
+
* behind a bounded connection retry — the first connect to a freshly
|
|
64
|
+
* provisioned Postgres is rejected while the upstream is cold (FT-5226).
|
|
65
|
+
*
|
|
66
|
+
* Runtime engine code (`bun` SQL + `node:crypto`); NOT re-exported from the
|
|
67
|
+
* authoring barrel.
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_MAX_KEYS$1 = 1e3;
|
|
70
|
+
function etagOf(bytes) {
|
|
71
|
+
return `"${createHash("sha256").update(bytes).digest("hex")}"`;
|
|
72
|
+
}
|
|
73
|
+
/** bytea comes back as a Node Buffer (a Uint8Array). Fail closed on anything else rather than returning wrong bytes. */
|
|
74
|
+
function toBytes(value) {
|
|
75
|
+
if (value instanceof Uint8Array) return value;
|
|
76
|
+
throw new TypeError(`expected bytea to decode as Uint8Array, got ${typeof value}`);
|
|
77
|
+
}
|
|
78
|
+
/** One row → GetResult (both the whole-object and ranged queries alias the payload as `bytes`; `size` is the bigint total). */
|
|
79
|
+
function toGetResult(row) {
|
|
80
|
+
return {
|
|
81
|
+
bytes: toBytes(row.bytes),
|
|
82
|
+
etag: row.etag,
|
|
83
|
+
contentType: row.content_type,
|
|
84
|
+
size: Number(row.size)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
var PgObjectStore = class {
|
|
88
|
+
sql;
|
|
89
|
+
constructor(sql) {
|
|
90
|
+
this.sql = sql;
|
|
91
|
+
}
|
|
92
|
+
async put(bucket, key, bytes, opts = {}) {
|
|
93
|
+
const etag = etagOf(bytes);
|
|
94
|
+
const contentType = opts.contentType ?? "application/octet-stream";
|
|
95
|
+
await this.sql`
|
|
96
|
+
insert into objects (bucket, key, bytes, size, etag, content_type)
|
|
97
|
+
values (${bucket}, ${key}, ${bytes}, ${bytes.byteLength}, ${etag}, ${contentType})
|
|
98
|
+
on conflict (bucket, key) do update set
|
|
99
|
+
bytes = excluded.bytes, size = excluded.size,
|
|
100
|
+
etag = excluded.etag, content_type = excluded.content_type`;
|
|
101
|
+
return { etag };
|
|
102
|
+
}
|
|
103
|
+
async get(bucket, key, opts = {}) {
|
|
104
|
+
const range = opts.range;
|
|
105
|
+
if (range) {
|
|
106
|
+
const from = range.start + 1;
|
|
107
|
+
const row = (range.end === void 0 ? await this.sql`select substring(bytes from ${from}) as bytes, size, etag, content_type
|
|
108
|
+
from objects where bucket = ${bucket} and key = ${key}` : await this.sql`select substring(bytes from ${from} for ${range.end - range.start + 1}) as bytes,
|
|
109
|
+
size, etag, content_type
|
|
110
|
+
from objects where bucket = ${bucket} and key = ${key}`)[0];
|
|
111
|
+
return row === void 0 ? null : toGetResult(row);
|
|
112
|
+
}
|
|
113
|
+
const row = (await this.sql`select bytes, size, etag, content_type
|
|
114
|
+
from objects where bucket = ${bucket} and key = ${key}`)[0];
|
|
115
|
+
return row === void 0 ? null : toGetResult(row);
|
|
116
|
+
}
|
|
117
|
+
async head(bucket, key) {
|
|
118
|
+
const row = (await this.sql`select size, etag, content_type
|
|
119
|
+
from objects where bucket = ${bucket} and key = ${key}`)[0];
|
|
120
|
+
if (row === void 0) return null;
|
|
121
|
+
return {
|
|
122
|
+
etag: row.etag,
|
|
123
|
+
size: Number(row.size),
|
|
124
|
+
contentType: row.content_type
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async delete(bucket, key) {
|
|
128
|
+
await this.sql`delete from objects where bucket = ${bucket} and key = ${key}`;
|
|
129
|
+
}
|
|
130
|
+
async list(bucket, opts = {}) {
|
|
131
|
+
const prefix = opts.prefix ?? "";
|
|
132
|
+
const maxKeys = opts.maxKeys ?? DEFAULT_MAX_KEYS$1;
|
|
133
|
+
const token = opts.continuationToken;
|
|
134
|
+
const limit = maxKeys + 1;
|
|
135
|
+
const keys = (token === void 0 ? await this.sql`select key from objects
|
|
136
|
+
where bucket = ${bucket} and starts_with(key, ${prefix})
|
|
137
|
+
order by key limit ${limit}` : await this.sql`select key from objects
|
|
138
|
+
where bucket = ${bucket} and starts_with(key, ${prefix}) and key > ${token}
|
|
139
|
+
order by key limit ${limit}`).map((r) => r.key);
|
|
140
|
+
const isTruncated = keys.length > maxKeys;
|
|
141
|
+
const page = isTruncated ? keys.slice(0, maxKeys) : keys;
|
|
142
|
+
const last = page.at(-1);
|
|
143
|
+
return {
|
|
144
|
+
keys: page,
|
|
145
|
+
isTruncated,
|
|
146
|
+
...isTruncated && last !== void 0 ? { nextContinuationToken: last } : {}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
/**
|
|
151
|
+
* Connect (FT-5219 posture: `max: 1`, short `idleTimeout`), apply the schema
|
|
152
|
+
* idempotently behind the cold-start retry, and return the store.
|
|
153
|
+
*/
|
|
154
|
+
async function createPgStore(url) {
|
|
155
|
+
const sql = new SQL({
|
|
156
|
+
url,
|
|
157
|
+
max: 1,
|
|
158
|
+
idleTimeout: 10
|
|
159
|
+
});
|
|
160
|
+
await retryTransientConnect(() => sql`
|
|
161
|
+
create table if not exists objects (
|
|
162
|
+
bucket text not null,
|
|
163
|
+
key text not null,
|
|
164
|
+
bytes bytea not null,
|
|
165
|
+
size bigint not null,
|
|
166
|
+
etag text not null,
|
|
167
|
+
content_type text not null,
|
|
168
|
+
created_at timestamptz not null default now(),
|
|
169
|
+
primary key (bucket, key)
|
|
170
|
+
)`);
|
|
171
|
+
return new PgObjectStore(sql);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* AWS SigV4 verification for the S3 wire protocol (spec § 2 auth). The payload
|
|
175
|
+
* hash comes from the client — `x-amz-content-sha256` (a real hash or
|
|
176
|
+
* `UNSIGNED-PAYLOAD`) for header auth, `UNSIGNED-PAYLOAD` for presign — and is
|
|
177
|
+
* never re-hashed; the verifier trusts what was signed, like a real S3 endpoint.
|
|
178
|
+
* Runtime engine code (`node:crypto`); not re-exported from the authoring barrel.
|
|
179
|
+
*/
|
|
180
|
+
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
181
|
+
const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
|
|
182
|
+
function sha256Hex(data) {
|
|
183
|
+
return createHash("sha256").update(data).digest("hex");
|
|
184
|
+
}
|
|
185
|
+
function hmac(key, data) {
|
|
186
|
+
return createHmac("sha256", key).update(data).digest();
|
|
187
|
+
}
|
|
188
|
+
/** AWS canonical URI encoding: every byte except the unreserved set is %XX. */
|
|
189
|
+
function awsUriEncode(value) {
|
|
190
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`).replace(/%7E/g, "~");
|
|
191
|
+
}
|
|
192
|
+
function parseCredential(credential) {
|
|
193
|
+
const parts = credential.split("/");
|
|
194
|
+
if (parts.length !== 5 || parts[4] !== "aws4_request") return null;
|
|
195
|
+
const [accessKeyId, date, region, service] = parts;
|
|
196
|
+
if (!accessKeyId || !date || !region || !service) return null;
|
|
197
|
+
return {
|
|
198
|
+
accessKeyId,
|
|
199
|
+
date,
|
|
200
|
+
region,
|
|
201
|
+
service
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function signingKey(secret, scope) {
|
|
205
|
+
return hmac(hmac(hmac(hmac(`AWS4${secret}`, scope.date), scope.region), scope.service), "aws4_request");
|
|
206
|
+
}
|
|
207
|
+
function canonicalHeaders(url, req, signedHeaders) {
|
|
208
|
+
return signedHeaders.map((name) => {
|
|
209
|
+
return `${name}:${(name === "host" ? url.host : req.headers.get(name) ?? "").trim().replace(/\s+/g, " ")}\n`;
|
|
210
|
+
}).join("");
|
|
211
|
+
}
|
|
212
|
+
function canonicalQuery(url, exclude) {
|
|
213
|
+
const entries = [];
|
|
214
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
215
|
+
if (exclude !== void 0 && key === exclude) continue;
|
|
216
|
+
entries.push([awsUriEncode(key), awsUriEncode(value)]);
|
|
217
|
+
}
|
|
218
|
+
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
219
|
+
entries.sort(([ak, av], [bk, bv]) => cmp(ak, bk) || cmp(av, bv));
|
|
220
|
+
return entries.map(([k, v]) => `${k}=${v}`).join("&");
|
|
221
|
+
}
|
|
222
|
+
function stringToSign(amzDate, scope, canonicalRequest) {
|
|
223
|
+
const scopeString = `${scope.date}/${scope.region}/${scope.service}/aws4_request`;
|
|
224
|
+
return [
|
|
225
|
+
ALGORITHM,
|
|
226
|
+
amzDate,
|
|
227
|
+
scopeString,
|
|
228
|
+
sha256Hex(canonicalRequest)
|
|
229
|
+
].join("\n");
|
|
230
|
+
}
|
|
231
|
+
function signatureMatches(expected, provided) {
|
|
232
|
+
const a = Buffer.from(expected, "hex");
|
|
233
|
+
const b = Buffer.from(provided, "hex");
|
|
234
|
+
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
|
|
235
|
+
}
|
|
236
|
+
/** `YYYYMMDDTHHMMSSZ` → epoch ms, or null when malformed. */
|
|
237
|
+
function parseAmzDate(amzDate) {
|
|
238
|
+
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(amzDate);
|
|
239
|
+
if (!match) return null;
|
|
240
|
+
const [, y, mo, d, h, mi, s] = match;
|
|
241
|
+
return Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
|
242
|
+
}
|
|
243
|
+
function parseAuthorizationHeader(header) {
|
|
244
|
+
if (!header.startsWith(`${ALGORITHM} `)) return null;
|
|
245
|
+
const rest = header.slice(17);
|
|
246
|
+
const fields = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const part of rest.split(",")) {
|
|
248
|
+
const eq = part.indexOf("=");
|
|
249
|
+
if (eq === -1) continue;
|
|
250
|
+
fields.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
|
|
251
|
+
}
|
|
252
|
+
const credential = fields.get("Credential");
|
|
253
|
+
const signedHeaders = fields.get("SignedHeaders");
|
|
254
|
+
const signature = fields.get("Signature");
|
|
255
|
+
if (!credential || !signedHeaders || !signature) return null;
|
|
256
|
+
return {
|
|
257
|
+
credential,
|
|
258
|
+
signedHeaders: signedHeaders.split(";"),
|
|
259
|
+
signature
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** The one signing core both auth forms share: check the access key, rebuild the canonical request, derive the key, compare in constant time. */
|
|
263
|
+
function verifySignature(req, url, credentials, params) {
|
|
264
|
+
if (params.scope.accessKeyId !== credentials.accessKeyId) return {
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: "unknown access key"
|
|
267
|
+
};
|
|
268
|
+
const canonicalRequest = [
|
|
269
|
+
req.method,
|
|
270
|
+
url.pathname,
|
|
271
|
+
canonicalQuery(url, params.excludeQuery),
|
|
272
|
+
canonicalHeaders(url, req, params.signedHeaders),
|
|
273
|
+
params.signedHeaders.join(";"),
|
|
274
|
+
params.payloadHash
|
|
275
|
+
].join("\n");
|
|
276
|
+
return signatureMatches(hmac(signingKey(credentials.secretAccessKey, params.scope), stringToSign(params.amzDate, params.scope, canonicalRequest)).toString("hex"), params.signature) ? { ok: true } : {
|
|
277
|
+
ok: false,
|
|
278
|
+
reason: "signature mismatch"
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function verifyHeader(req, url, credentials) {
|
|
282
|
+
const auth = parseAuthorizationHeader(req.headers.get("authorization") ?? "");
|
|
283
|
+
if (!auth) return {
|
|
284
|
+
ok: false,
|
|
285
|
+
reason: "malformed Authorization header"
|
|
286
|
+
};
|
|
287
|
+
const scope = parseCredential(auth.credential);
|
|
288
|
+
if (!scope) return {
|
|
289
|
+
ok: false,
|
|
290
|
+
reason: "malformed credential scope"
|
|
291
|
+
};
|
|
292
|
+
const amzDate = req.headers.get("x-amz-date");
|
|
293
|
+
if (!amzDate) return {
|
|
294
|
+
ok: false,
|
|
295
|
+
reason: "missing x-amz-date"
|
|
296
|
+
};
|
|
297
|
+
const payloadHash = req.headers.get("x-amz-content-sha256");
|
|
298
|
+
if (!payloadHash) return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: "missing x-amz-content-sha256"
|
|
301
|
+
};
|
|
302
|
+
return verifySignature(req, url, credentials, {
|
|
303
|
+
scope,
|
|
304
|
+
amzDate,
|
|
305
|
+
signedHeaders: auth.signedHeaders,
|
|
306
|
+
payloadHash,
|
|
307
|
+
signature: auth.signature
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function verifyPresigned(req, url, credentials, now) {
|
|
311
|
+
const q = url.searchParams;
|
|
312
|
+
if (q.get("X-Amz-Algorithm") !== ALGORITHM) return {
|
|
313
|
+
ok: false,
|
|
314
|
+
reason: "unsupported presign algorithm"
|
|
315
|
+
};
|
|
316
|
+
const credentialRaw = q.get("X-Amz-Credential");
|
|
317
|
+
const amzDate = q.get("X-Amz-Date");
|
|
318
|
+
const expiresRaw = q.get("X-Amz-Expires");
|
|
319
|
+
const signedHeadersRaw = q.get("X-Amz-SignedHeaders");
|
|
320
|
+
const signature = q.get("X-Amz-Signature");
|
|
321
|
+
if (!credentialRaw || !amzDate || !expiresRaw || !signedHeadersRaw || !signature) return {
|
|
322
|
+
ok: false,
|
|
323
|
+
reason: "incomplete presign parameters"
|
|
324
|
+
};
|
|
325
|
+
const scope = parseCredential(credentialRaw);
|
|
326
|
+
if (!scope) return {
|
|
327
|
+
ok: false,
|
|
328
|
+
reason: "malformed credential scope"
|
|
329
|
+
};
|
|
330
|
+
const signedAt = parseAmzDate(amzDate);
|
|
331
|
+
const expires = Number(expiresRaw);
|
|
332
|
+
if (signedAt === null || !Number.isFinite(expires)) return {
|
|
333
|
+
ok: false,
|
|
334
|
+
reason: "malformed presign date"
|
|
335
|
+
};
|
|
336
|
+
if (now.getTime() > signedAt + expires * 1e3) return {
|
|
337
|
+
ok: false,
|
|
338
|
+
reason: "presign expired"
|
|
339
|
+
};
|
|
340
|
+
return verifySignature(req, url, credentials, {
|
|
341
|
+
scope,
|
|
342
|
+
amzDate,
|
|
343
|
+
signedHeaders: signedHeadersRaw.split(";"),
|
|
344
|
+
payloadHash: UNSIGNED_PAYLOAD,
|
|
345
|
+
signature,
|
|
346
|
+
excludeQuery: "X-Amz-Signature"
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Verify a request's SigV4 signature against a single credential pair. Picks
|
|
351
|
+
* the presigned form when `X-Amz-Signature` is present, otherwise the
|
|
352
|
+
* `Authorization`-header form. `now` is injectable for deterministic
|
|
353
|
+
* expiry tests.
|
|
354
|
+
*/
|
|
355
|
+
function verifyRequest(req, credentials, now = /* @__PURE__ */ new Date()) {
|
|
356
|
+
const url = new URL(req.url);
|
|
357
|
+
if (url.searchParams.has("X-Amz-Signature")) return verifyPresigned(req, url, credentials, now);
|
|
358
|
+
if (req.headers.has("authorization")) return verifyHeader(req, url, credentials);
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
reason: "unsigned request"
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
365
|
+
/** Path-style: `/{bucket}/{key…}`. Each segment is percent-decoded. */
|
|
366
|
+
function parseTarget(url) {
|
|
367
|
+
const segments = url.pathname.split("/").filter((s) => s.length > 0);
|
|
368
|
+
if (segments.length === 0) return null;
|
|
369
|
+
const [bucket, ...keyParts] = segments;
|
|
370
|
+
return {
|
|
371
|
+
bucket: decodeURIComponent(bucket ?? ""),
|
|
372
|
+
key: keyParts.map(decodeURIComponent).join("/")
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** `bytes=a-b` (inclusive) or `bytes=a-` (open-ended). Null when absent/malformed. */
|
|
376
|
+
function parseRange(header) {
|
|
377
|
+
if (!header) return null;
|
|
378
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
|
|
379
|
+
if (!match) return null;
|
|
380
|
+
const start = Number(match[1]);
|
|
381
|
+
return match[2] ? {
|
|
382
|
+
start,
|
|
383
|
+
end: Number(match[2])
|
|
384
|
+
} : { start };
|
|
385
|
+
}
|
|
386
|
+
function xmlEscape(value) {
|
|
387
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
388
|
+
}
|
|
389
|
+
function listXml(bucket, prefix, maxKeys, result) {
|
|
390
|
+
const contents = result.keys.map((k) => `<Contents><Key>${xmlEscape(k)}</Key></Contents>`).join("");
|
|
391
|
+
const next = result.isTruncated && result.nextContinuationToken !== void 0 ? `<NextContinuationToken>${xmlEscape(result.nextContinuationToken)}</NextContinuationToken>` : "";
|
|
392
|
+
return `<?xml version="1.0" encoding="UTF-8"?><ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>${xmlEscape(bucket)}</Name><Prefix>${xmlEscape(prefix)}</Prefix><KeyCount>${result.keys.length}</KeyCount><MaxKeys>${maxKeys}</MaxKeys><IsTruncated>${result.isTruncated}</IsTruncated>` + contents + next + "</ListBucketResult>";
|
|
393
|
+
}
|
|
394
|
+
const DEFAULT_MAX_KEYS = 1e3;
|
|
395
|
+
async function handleList(store, bucket, url) {
|
|
396
|
+
const prefix = url.searchParams.get("prefix") ?? "";
|
|
397
|
+
const continuationToken = url.searchParams.get("continuation-token");
|
|
398
|
+
const maxKeysRaw = url.searchParams.get("max-keys");
|
|
399
|
+
const maxKeys = maxKeysRaw !== null && Number.isFinite(Number(maxKeysRaw)) ? Number(maxKeysRaw) : DEFAULT_MAX_KEYS;
|
|
400
|
+
const result = await store.list(bucket, {
|
|
401
|
+
prefix,
|
|
402
|
+
maxKeys,
|
|
403
|
+
...continuationToken !== null ? { continuationToken } : {}
|
|
404
|
+
});
|
|
405
|
+
return new Response(listXml(bucket, prefix, maxKeys, result), {
|
|
406
|
+
status: 200,
|
|
407
|
+
headers: { "content-type": "application/xml" }
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** aws-chunked / flexible-checksum PUTs frame the body as chunks + a trailer (signalled by `x-amz-content-sha256: STREAMING-…` or `content-encoding: aws-chunked`); the seed signature still verifies, so reject them (501) rather than store the raw framing as the object bytes. Decoding is out of scope. */
|
|
411
|
+
function isStreamingPut(req) {
|
|
412
|
+
const contentSha = req.headers.get("x-amz-content-sha256") ?? "";
|
|
413
|
+
const contentEncoding = req.headers.get("content-encoding") ?? "";
|
|
414
|
+
return contentSha.startsWith("STREAMING-") || contentEncoding.split(",").some((e) => e.trim() === "aws-chunked");
|
|
415
|
+
}
|
|
416
|
+
async function handlePut(store, t, req) {
|
|
417
|
+
if (isStreamingPut(req)) return new Response("aws-chunked / flexible checksums not supported; set requestChecksumCalculation: 'WHEN_REQUIRED'", { status: 501 });
|
|
418
|
+
const body = new Uint8Array(await req.arrayBuffer());
|
|
419
|
+
const contentType = req.headers.get("content-type") ?? DEFAULT_CONTENT_TYPE;
|
|
420
|
+
const { etag } = await store.put(t.bucket, t.key, body, { contentType });
|
|
421
|
+
return new Response(null, {
|
|
422
|
+
status: 200,
|
|
423
|
+
headers: { etag }
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
/** The etag/content-type/content-length/accept-ranges headers GET and HEAD share — `contentLength` is the slice length for GET, the total object size for HEAD. */
|
|
427
|
+
function metaHeaders(meta) {
|
|
428
|
+
return new Headers({
|
|
429
|
+
etag: meta.etag,
|
|
430
|
+
"content-type": meta.contentType,
|
|
431
|
+
"content-length": String(meta.contentLength),
|
|
432
|
+
"accept-ranges": "bytes"
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
async function handleGet(store, t, req) {
|
|
436
|
+
const range = parseRange(req.headers.get("range"));
|
|
437
|
+
const object = await store.get(t.bucket, t.key, range ? { range } : void 0);
|
|
438
|
+
if (!object) return new Response(null, { status: 404 });
|
|
439
|
+
const headers = metaHeaders({
|
|
440
|
+
etag: object.etag,
|
|
441
|
+
contentType: object.contentType,
|
|
442
|
+
contentLength: object.bytes.byteLength
|
|
443
|
+
});
|
|
444
|
+
if (!range) return new Response(object.bytes, {
|
|
445
|
+
status: 200,
|
|
446
|
+
headers
|
|
447
|
+
});
|
|
448
|
+
if (range.start >= object.size && object.size > 0) return new Response(null, {
|
|
449
|
+
status: 416,
|
|
450
|
+
headers: { "content-range": `bytes */${object.size}` }
|
|
451
|
+
});
|
|
452
|
+
const end = range.end === void 0 ? object.size - 1 : Math.min(range.end, object.size - 1);
|
|
453
|
+
headers.set("content-range", `bytes ${range.start}-${end}/${object.size}`);
|
|
454
|
+
return new Response(object.bytes, {
|
|
455
|
+
status: 206,
|
|
456
|
+
headers
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
async function handleHead(store, t) {
|
|
460
|
+
const meta = await store.head(t.bucket, t.key);
|
|
461
|
+
if (!meta) return new Response(null, { status: 404 });
|
|
462
|
+
return new Response(null, {
|
|
463
|
+
status: 200,
|
|
464
|
+
headers: metaHeaders({
|
|
465
|
+
etag: meta.etag,
|
|
466
|
+
contentType: meta.contentType,
|
|
467
|
+
contentLength: meta.size
|
|
468
|
+
})
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async function handleDelete(store, t) {
|
|
472
|
+
await store.delete(t.bucket, t.key);
|
|
473
|
+
return new Response(null, { status: 204 });
|
|
474
|
+
}
|
|
475
|
+
function createS3Handler(opts) {
|
|
476
|
+
const { store, credentials } = opts;
|
|
477
|
+
return async (req) => {
|
|
478
|
+
if (!verifyRequest(req, credentials).ok) return new Response(null, { status: 403 });
|
|
479
|
+
const url = new URL(req.url);
|
|
480
|
+
const target = parseTarget(url);
|
|
481
|
+
if (!target) return new Response(null, { status: 400 });
|
|
482
|
+
if (req.method === "GET" && url.searchParams.get("list-type") === "2" && target.key === "") return handleList(store, target.bucket, url);
|
|
483
|
+
if (target.key === "") return new Response(null, { status: 400 });
|
|
484
|
+
switch (req.method) {
|
|
485
|
+
case "PUT": return handlePut(store, target, req);
|
|
486
|
+
case "GET": return handleGet(store, target, req);
|
|
487
|
+
case "HEAD": return handleHead(store, target);
|
|
488
|
+
case "DELETE": return handleDelete(store, target);
|
|
489
|
+
default: return new Response(null, { status: 405 });
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Boots the S3 wire protocol on `Bun.serve` — the D2 handler over any
|
|
495
|
+
* `ObjectStore`. Binds all interfaces (Compute routes external HTTP to the VM,
|
|
496
|
+
* so a loopback-only listener would be unreachable). Installs the FT-5219
|
|
497
|
+
* process guards so an idle Bun.SQL connection close surfaces as a logged
|
|
498
|
+
* error instead of crash-looping the process on scale-to-zero.
|
|
499
|
+
*
|
|
500
|
+
* Runtime engine code; NOT re-exported from the authoring barrel. The D4
|
|
501
|
+
* entrypoint reads deps via `load()` and calls this.
|
|
502
|
+
*/
|
|
503
|
+
let guardsInstalled = false;
|
|
504
|
+
/** FT-5219: keep the process alive when Bun.SQL surfaces an idle-close as an unawaited async error. Installed once. */
|
|
505
|
+
function installProcessGuards() {
|
|
506
|
+
if (guardsInstalled) return;
|
|
507
|
+
guardsInstalled = true;
|
|
508
|
+
process.on("uncaughtException", (err) => console.error("uncaughtException", err));
|
|
509
|
+
process.on("unhandledRejection", (err) => console.error("unhandledRejection", err));
|
|
510
|
+
}
|
|
511
|
+
function startStorageServer(opts) {
|
|
512
|
+
installProcessGuards();
|
|
513
|
+
const handler = createS3Handler({
|
|
514
|
+
store: opts.store,
|
|
515
|
+
credentials: opts.credentials
|
|
516
|
+
});
|
|
517
|
+
const hostname = opts.hostname ?? "0.0.0.0";
|
|
518
|
+
const server = Bun.serve({
|
|
519
|
+
port: opts.port,
|
|
520
|
+
hostname,
|
|
521
|
+
fetch: (req) => handler(req)
|
|
522
|
+
});
|
|
523
|
+
return {
|
|
524
|
+
url: `http://${hostname === "0.0.0.0" ? "127.0.0.1" : hostname}:${server.port}`,
|
|
525
|
+
stop: () => server.stop(true)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
//#endregion
|
|
529
|
+
export { createPgStore, startStorageServer };
|
|
530
|
+
|
|
531
|
+
//# sourceMappingURL=testing.mjs.map
|