@mdrnio/sdk 0.0.0-stage → 0.3.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 +21 -0
- package/README.md +217 -2
- package/package.json +38 -4
- package/src/browser.d.ts +72 -0
- package/src/browser.js +190 -0
- package/src/fake.d.ts +63 -0
- package/src/fake.js +412 -0
- package/src/index.d.ts +257 -0
- package/src/index.js +360 -0
package/src/fake.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
// An in-memory MDRN for tests: `@mdrnio/sdk/fake`. It serves the 19 operations of
|
|
2
|
+
// sdk/contracts/client.openapi.json with the real response shapes, status codes and error
|
|
3
|
+
// codes, behind the headers MDRN's Proxy checks (the product token, MDRN-Project,
|
|
4
|
+
// MDRN-Environment, Idempotency-Key on every mutation, If-Match on record updates and deletes),
|
|
5
|
+
// and records every request it sees. Web APIs only, so it runs wherever the SDK runs.
|
|
6
|
+
//
|
|
7
|
+
// const fake = createFakeMdrn();
|
|
8
|
+
// const mdrn = Mdrn.fromEnv(fake.env, { transfer: fake.fetch });
|
|
9
|
+
//
|
|
10
|
+
// Deterministic by design: ids derive from idempotency keys, the clock starts at 2026-01-01 and
|
|
11
|
+
// advances one second per request, an upload is `uploading` on its first read and an object
|
|
12
|
+
// `ready` (or `rejected`) on its first read after completion, so a test never waits. Three
|
|
13
|
+
// collection names behave specially so error paths can be reached through the SDK:
|
|
14
|
+
// `broken` (a 502 without an error envelope), `redirected` (a 307), `contended` (the record's
|
|
15
|
+
// version grows by one on every read: a writer that always gets there first) and
|
|
16
|
+
// `contended-once` (once per record).
|
|
17
|
+
|
|
18
|
+
const IDENTIFIER = /^[A-Za-z0-9_-]{1,128}$/;
|
|
19
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
20
|
+
const LOGGED_HEADERS = ["authorization", "mdrn-project", "mdrn-environment", "idempotency-key", "if-match", "content-type", "x-mdrn-grant"];
|
|
21
|
+
const ATTACHMENT_TYPES = ["application/pdf", "image/png", "image/jpeg", "text/plain"];
|
|
22
|
+
const MAX_PAGE = 100;
|
|
23
|
+
|
|
24
|
+
const hex = (buffer) => [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
const sha256 = async (bytes) => hex(await crypto.subtle.digest("SHA-256", bytes));
|
|
26
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
const isText = (value, max, min = 1) => typeof value === "string" && value.length >= min && value.length <= max;
|
|
28
|
+
|
|
29
|
+
export function createFakeMdrn({
|
|
30
|
+
origin = "https://app.example",
|
|
31
|
+
filesOrigin = origin,
|
|
32
|
+
project = "project_one",
|
|
33
|
+
environment = "sandbox",
|
|
34
|
+
partBytes = 16 * 1024 * 1024,
|
|
35
|
+
tokens = {},
|
|
36
|
+
} = {}) {
|
|
37
|
+
const token = { database: "database-token", storage: "storage-token", mail: "mail-token", ...tokens };
|
|
38
|
+
const base = origin.replace(/\/$/, "");
|
|
39
|
+
const files = filesOrigin.replace(/\/$/, "");
|
|
40
|
+
const env = {
|
|
41
|
+
MDRN_PROJECT_ID: project,
|
|
42
|
+
MDRN_ENVIRONMENT: environment,
|
|
43
|
+
MDRN_DATABASE_URL: `${base}/_mdrn/v1/database`,
|
|
44
|
+
MDRN_DATABASE_TOKEN: token.database,
|
|
45
|
+
MDRN_STORAGE_URL: `${base}/v1/storage`,
|
|
46
|
+
MDRN_STORAGE_TOKEN: token.storage,
|
|
47
|
+
MDRN_MAIL_URL: `${base}/v1/mail`,
|
|
48
|
+
MDRN_MAIL_TOKEN: token.mail,
|
|
49
|
+
MDRN_PRODUCTS: { fetch: (input, init) => fetch(input, init) },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const collections = new Map();
|
|
53
|
+
const uploads = new Map();
|
|
54
|
+
const objects = new Map();
|
|
55
|
+
const grants = new Map();
|
|
56
|
+
const operations = new Map();
|
|
57
|
+
const templates = new Map([["welcome", { subject: "Welcome", text: "Welcome aboard.", html: null }]]);
|
|
58
|
+
const users = new Map();
|
|
59
|
+
const sent = [];
|
|
60
|
+
const log = [];
|
|
61
|
+
let replays = new Map();
|
|
62
|
+
let bumped = new Set();
|
|
63
|
+
let clock = Date.UTC(2026, 0, 1);
|
|
64
|
+
let requests = 0;
|
|
65
|
+
|
|
66
|
+
const now = () => new Date(clock).toISOString();
|
|
67
|
+
const at = (ms) => new Date(clock + ms).toISOString();
|
|
68
|
+
const id = async (prefix, key) => `${prefix}_${(await sha256(new TextEncoder().encode(`${project}:${environment}:${prefix}:${key}`))).slice(0, 32)}`;
|
|
69
|
+
const json = (status, body, headers = {}) => new Response(status === 204 ? null : JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers } });
|
|
70
|
+
const fail = (status, code, message) => json(status, { error: { code, message, retryable: status === 503, requestId: `req_${requests}` } });
|
|
71
|
+
|
|
72
|
+
function collection(name) {
|
|
73
|
+
if (!collections.has(name)) collections.set(name, new Map());
|
|
74
|
+
return collections.get(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A page of `items` for the limit and cursor of `query`: cursors are `cursor-<offset>`.
|
|
78
|
+
function page(items, query, invalid) {
|
|
79
|
+
const extra = [...query.keys()].filter((k) => !["limit", "cursor"].includes(k));
|
|
80
|
+
if (extra.length) return fail(400, invalid, `Unsupported list parameter: ${extra[0]}`);
|
|
81
|
+
let limit = 50;
|
|
82
|
+
if (query.has("limit")) {
|
|
83
|
+
limit = Number(query.get("limit"));
|
|
84
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_PAGE) return fail(400, invalid, "List limit exceeds configured page maximum");
|
|
85
|
+
}
|
|
86
|
+
let offset = 0;
|
|
87
|
+
if (query.has("cursor")) {
|
|
88
|
+
const match = /^cursor-(\d+)$/.exec(query.get("cursor"));
|
|
89
|
+
if (!match) return fail(400, invalid, "Invalid cursor");
|
|
90
|
+
offset = Number(match[1]);
|
|
91
|
+
}
|
|
92
|
+
const slice = items.slice(offset, offset + limit);
|
|
93
|
+
return json(200, { items: slice, nextCursor: offset + limit < items.length ? `cursor-${offset + limit}` : null });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const uploadView = (u) => ({ id: u.id, objectId: u.objectId, state: u.state, expiresAt: u.expiresAt, operationId: u.operationId, partBytes });
|
|
97
|
+
const objectView = (o) => ({ id: o.id, filename: o.filename, contentType: o.contentType, bytes: o.bytes, sha256: o.sha256, state: o.state, createdAt: o.createdAt, deletedAt: o.deletedAt, purgeAt: o.purgeAt });
|
|
98
|
+
const partView = (p) => ({ partNumber: p.partNumber, bytes: p.bytes, etag: p.etag, state: p.state });
|
|
99
|
+
|
|
100
|
+
async function database(method, path, query, body, key, ifMatch) {
|
|
101
|
+
const match = /^\/collections\/([^/]+)\/records(?:\/([^/]+))?$/.exec(path);
|
|
102
|
+
if (!match) return fail(404, "NOT_FOUND", "Unknown record route");
|
|
103
|
+
const name = decodeURIComponent(match[1]);
|
|
104
|
+
const recordId = match[2] === undefined ? undefined : decodeURIComponent(match[2]);
|
|
105
|
+
if (name === "broken") return new Response("Bad gateway", { status: 502, headers: { "content-type": "text/plain" } });
|
|
106
|
+
if (name === "redirected") return new Response(null, { status: 307, headers: { location: "https://elsewhere.example/" } });
|
|
107
|
+
const records = collection(name);
|
|
108
|
+
if (recordId === undefined) {
|
|
109
|
+
if (method === "GET") return page([...records.values()], query, "INVALID_REQUEST");
|
|
110
|
+
if (method !== "POST") return fail(405, "INVALID_REQUEST", "Unsupported record operation");
|
|
111
|
+
if (!isObject(body) || !isObject(body.data)) return fail(400, "INVALID_REQUEST", "Expected a data object");
|
|
112
|
+
const record = { id: await id("rec", key), collection: name, version: 1, data: structuredClone(body.data), createdAt: now(), updatedAt: now() };
|
|
113
|
+
records.set(record.id, record);
|
|
114
|
+
return json(201, record);
|
|
115
|
+
}
|
|
116
|
+
const record = records.get(recordId);
|
|
117
|
+
if (!record) return fail(404, "NOT_FOUND", "Record not found");
|
|
118
|
+
if (method === "GET") {
|
|
119
|
+
const view = structuredClone(record);
|
|
120
|
+
// A concurrent writer, simulated: the version read is stale by the time it is used.
|
|
121
|
+
if (name === "contended" || (name === "contended-once" && !bumped.has(record.id))) {
|
|
122
|
+
bumped.add(record.id);
|
|
123
|
+
record.version += 1;
|
|
124
|
+
record.updatedAt = now();
|
|
125
|
+
}
|
|
126
|
+
return json(200, view);
|
|
127
|
+
}
|
|
128
|
+
if (method !== "PATCH" && method !== "DELETE") return fail(405, "INVALID_REQUEST", "Unsupported record operation");
|
|
129
|
+
if (ifMatch === undefined) return fail(400, "INVALID_REQUEST", "If-Match is required");
|
|
130
|
+
if (ifMatch !== record.version) return fail(409, "CONFLICT", "If-Match must equal the current record version");
|
|
131
|
+
if (method === "DELETE") {
|
|
132
|
+
records.delete(recordId);
|
|
133
|
+
return json(200, { deleted: true });
|
|
134
|
+
}
|
|
135
|
+
if (!isObject(body) || !isObject(body.data)) return fail(400, "INVALID_REQUEST", "Expected a data object");
|
|
136
|
+
// A shallow merge: the fields given replace or join the record's; the others stay.
|
|
137
|
+
record.data = { ...record.data, ...structuredClone(body.data) };
|
|
138
|
+
record.version += 1;
|
|
139
|
+
record.updatedAt = now();
|
|
140
|
+
return json(200, structuredClone(record));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function storage(method, path, query, body, key) {
|
|
144
|
+
const route = `${method} ${path.replace(/^\/(uploads|objects|operations)\/[^/]+/, "/$1/{id}")}`;
|
|
145
|
+
const target = /^\/(?:uploads|objects|operations)\/([^/]+)/.exec(path)?.[1];
|
|
146
|
+
switch (route) {
|
|
147
|
+
case "POST /uploads": {
|
|
148
|
+
const keys = isObject(body) ? Object.keys(body) : [];
|
|
149
|
+
if (!isObject(body) || keys.some((k) => !["filename", "contentType", "bytes", "sha256"].includes(k))
|
|
150
|
+
|| !isText(body.filename, 255) || !isText(body.contentType, 128)
|
|
151
|
+
|| !Number.isInteger(body.bytes) || body.bytes < 0 || body.bytes > 10 * 1024 * 1024 * 1024
|
|
152
|
+
|| !/^[a-fA-F0-9]{64}$/.test(body.sha256 ?? "")) return fail(400, "INVALID_INPUT", "Invalid upload request");
|
|
153
|
+
const upload = {
|
|
154
|
+
id: await id("upl", key), objectId: await id("obj", key), state: "pending", next: "uploading", expiresAt: at(DAY), operationId: null,
|
|
155
|
+
filename: body.filename, contentType: body.contentType, bytes: body.bytes, sha256: body.sha256.toLowerCase(), parts: new Map(),
|
|
156
|
+
};
|
|
157
|
+
uploads.set(upload.id, upload);
|
|
158
|
+
return json(202, uploadView(upload));
|
|
159
|
+
}
|
|
160
|
+
case "GET /uploads": return page([...uploads.values()].map(uploadView), query, "INVALID_INPUT");
|
|
161
|
+
case "GET /uploads/{id}": {
|
|
162
|
+
const upload = uploads.get(target);
|
|
163
|
+
if (!upload) return fail(404, "NOT_FOUND", "Upload not found");
|
|
164
|
+
if (upload.next) { upload.state = upload.next; upload.next = null; }
|
|
165
|
+
return json(200, uploadView(upload));
|
|
166
|
+
}
|
|
167
|
+
case "DELETE /uploads/{id}": {
|
|
168
|
+
const upload = uploads.get(target);
|
|
169
|
+
if (!upload) return fail(404, "NOT_FOUND", "Upload not found");
|
|
170
|
+
if (!["pending", "uploading"].includes(upload.next ?? upload.state)) return fail(409, "CONFLICT", `Upload is ${upload.state}`);
|
|
171
|
+
upload.state = "aborted"; upload.next = null;
|
|
172
|
+
return json(204);
|
|
173
|
+
}
|
|
174
|
+
case "GET /uploads/{id}/parts": {
|
|
175
|
+
const upload = uploads.get(target);
|
|
176
|
+
if (!upload) return fail(404, "NOT_FOUND", "Upload not found");
|
|
177
|
+
return page([...upload.parts.values()].sort((a, b) => a.partNumber - b.partNumber).map(partView), query, "INVALID_INPUT");
|
|
178
|
+
}
|
|
179
|
+
case "POST /uploads/{id}/parts": {
|
|
180
|
+
const upload = uploads.get(target);
|
|
181
|
+
if (!upload) return fail(404, "NOT_FOUND", "Upload not found");
|
|
182
|
+
if (upload.state !== "uploading") return fail(409, "CONFLICT", `Upload is ${upload.state}, not uploading`);
|
|
183
|
+
if (!isObject(body) || Object.keys(body).some((k) => !["partNumber", "bytes"].includes(k))
|
|
184
|
+
|| !Number.isInteger(body.partNumber) || body.partNumber < 1 || body.partNumber > 640
|
|
185
|
+
|| !Number.isInteger(body.bytes) || body.bytes < 1 || body.bytes > partBytes) return fail(400, "INVALID_INPUT", "Invalid part request");
|
|
186
|
+
const grant = { id: await id("grant", key), method: "PUT", uploadId: upload.id, partNumber: body.partNumber, maxBytes: body.bytes, expiresAt: at(15 * 60 * 1000) };
|
|
187
|
+
grants.set(grant.id, grant);
|
|
188
|
+
return json(202, { id: grant.id, url: `${files}/transfer/${grant.id}`, method: "PUT", headers: { "x-mdrn-grant": grant.id }, expiresAt: grant.expiresAt, maxBytes: grant.maxBytes });
|
|
189
|
+
}
|
|
190
|
+
case "POST /uploads/{id}/complete": {
|
|
191
|
+
const upload = uploads.get(target);
|
|
192
|
+
if (!upload) return fail(404, "NOT_FOUND", "Upload not found");
|
|
193
|
+
if (upload.state !== "uploading") return fail(409, "CONFLICT", `Upload is ${upload.state}, not uploading`);
|
|
194
|
+
if (!isObject(body) || !Array.isArray(body.parts) || body.parts.length > 640
|
|
195
|
+
|| body.parts.some((p) => !isObject(p) || !Number.isInteger(p.partNumber) || typeof p.etag !== "string")) return fail(400, "INVALID_INPUT", "Invalid completion request");
|
|
196
|
+
const ready = [...upload.parts.values()].filter((p) => p.state === "ready");
|
|
197
|
+
const given = new Map(body.parts.map((p) => [p.partNumber, p.etag]));
|
|
198
|
+
if (given.size !== body.parts.length || given.size !== ready.length || ready.some((p) => given.get(p.partNumber) !== p.etag)) {
|
|
199
|
+
return fail(400, "INVALID_INPUT", "Parts do not match the upload's ready parts");
|
|
200
|
+
}
|
|
201
|
+
const data = new Uint8Array(ready.reduce((n, p) => n + p.bytes, 0));
|
|
202
|
+
let offset = 0;
|
|
203
|
+
for (const part of ready.sort((a, b) => a.partNumber - b.partNumber)) { data.set(part.data, offset); offset += part.bytes; }
|
|
204
|
+
const verified = data.byteLength === upload.bytes && (await sha256(data)) === upload.sha256;
|
|
205
|
+
const operation = { id: await id("op", key), kind: "verify", state: verified ? "succeeded" : "failed", processedBytes: data.byteLength, processedItems: ready.length, attempts: 1, retryAt: null, error: verified ? null : "SHA256_MISMATCH" };
|
|
206
|
+
operations.set(operation.id, operation);
|
|
207
|
+
objects.set(upload.objectId, { id: upload.objectId, filename: upload.filename, contentType: upload.contentType, bytes: upload.bytes, sha256: upload.sha256, state: "verifying", next: verified ? "ready" : "rejected", createdAt: now(), deletedAt: null, purgeAt: null, data });
|
|
208
|
+
upload.state = "verifying"; upload.next = verified ? "complete" : "rejected"; upload.operationId = operation.id;
|
|
209
|
+
return json(202, { id: upload.objectId, filename: upload.filename, contentType: upload.contentType, bytes: upload.bytes, state: "verifying", operationId: operation.id });
|
|
210
|
+
}
|
|
211
|
+
case "GET /objects": {
|
|
212
|
+
if (query.has("state") && query.get("state") !== "ready") return fail(403, "FORBIDDEN", "Listing deleted or lost objects requires management authority");
|
|
213
|
+
query.delete("state");
|
|
214
|
+
return page([...objects.values()].filter((o) => (o.next ?? o.state) === "ready").map((o) => objectView(o.next ? { ...o, state: o.next } : o)), query, "INVALID_INPUT");
|
|
215
|
+
}
|
|
216
|
+
case "GET /objects/{id}": {
|
|
217
|
+
const object = objects.get(target);
|
|
218
|
+
if (!object || object.state === "deleted") return fail(404, "NOT_FOUND", "Object not found");
|
|
219
|
+
if (object.next) { object.state = object.next; object.next = null; }
|
|
220
|
+
return json(200, objectView(object));
|
|
221
|
+
}
|
|
222
|
+
case "DELETE /objects/{id}": {
|
|
223
|
+
const object = objects.get(target);
|
|
224
|
+
if (!object || object.state === "deleted") return fail(404, "NOT_FOUND", "Object not found");
|
|
225
|
+
object.state = "deleted"; object.next = null; object.deletedAt = now(); object.purgeAt = at(30 * DAY);
|
|
226
|
+
return json(204);
|
|
227
|
+
}
|
|
228
|
+
case "POST /objects/{id}/download-grants": {
|
|
229
|
+
const object = objects.get(target);
|
|
230
|
+
if (!object || object.state === "deleted") return fail(404, "NOT_FOUND", "Object not found");
|
|
231
|
+
if ((object.next ?? object.state) !== "ready") return fail(409, "CONFLICT", `Object is ${object.next ?? object.state}, not ready`);
|
|
232
|
+
const grant = { id: await id("grant", key), method: "GET", objectId: object.id, maxBytes: object.bytes, expiresAt: at(15 * 60 * 1000) };
|
|
233
|
+
grants.set(grant.id, grant);
|
|
234
|
+
return json(200, { id: grant.id, url: `${files}/transfer/${grant.id}`, method: "GET", headers: {}, expiresAt: grant.expiresAt, maxBytes: grant.maxBytes });
|
|
235
|
+
}
|
|
236
|
+
case "GET /operations/{id}": {
|
|
237
|
+
const operation = operations.get(target);
|
|
238
|
+
return operation ? json(200, operation) : fail(404, "NOT_FOUND", "Operation not found");
|
|
239
|
+
}
|
|
240
|
+
default: return fail(404, "NOT_FOUND", "Unknown Storage route");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The Files gateway: a grant URL is the whole credential, so a product token or the MDRN
|
|
245
|
+
// headers on such a request are a leak, and the fake refuses them to make that visible.
|
|
246
|
+
async function transfer(request, grantId) {
|
|
247
|
+
if (request.headers.has("authorization") || request.headers.has("mdrn-project") || request.headers.has("idempotency-key")) {
|
|
248
|
+
return fail(403, "GRANT_REJECTED", "A grant URL takes no credentials: send only the grant's own headers");
|
|
249
|
+
}
|
|
250
|
+
const grant = grants.get(grantId);
|
|
251
|
+
if (!grant || grant.method !== request.method) return fail(403, "GRANT_REJECTED", "Invalid, expired or revoked grant");
|
|
252
|
+
if (grant.method === "GET") {
|
|
253
|
+
const object = objects.get(grant.objectId);
|
|
254
|
+
if (!object || (object.next ?? object.state) !== "ready") return fail(404, "NOT_FOUND", "Object is not ready or no longer available");
|
|
255
|
+
return new Response(object.data, { status: 200, headers: { "content-type": object.contentType, "content-length": String(object.bytes), etag: `"${object.sha256.slice(0, 16)}"` } });
|
|
256
|
+
}
|
|
257
|
+
if (request.headers.get("x-mdrn-grant") !== grant.id) return fail(403, "GRANT_REJECTED", "The grant's headers are missing");
|
|
258
|
+
const upload = uploads.get(grant.uploadId);
|
|
259
|
+
if (!upload || upload.state !== "uploading") return fail(409, "CONFLICT", "The upload is not accepting parts");
|
|
260
|
+
const data = new Uint8Array(await request.arrayBuffer());
|
|
261
|
+
if (data.byteLength !== grant.maxBytes) return fail(400, "PART_SIZE_MISMATCH", "Part bytes do not match the grant");
|
|
262
|
+
const part = { partNumber: grant.partNumber, bytes: data.byteLength, etag: `"${(await sha256(data)).slice(0, 16)}"`, state: "ready", data };
|
|
263
|
+
upload.parts.set(part.partNumber, part);
|
|
264
|
+
return json(200, { partNumber: part.partNumber, bytes: part.bytes, etag: part.etag });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function mail(method, path, query, body, key) {
|
|
268
|
+
if (query.size) return fail(400, "INVALID_REQUEST", "Mail takes no query parameters");
|
|
269
|
+
if (method !== "POST" || path !== "/messages") return fail(404, "NOT_FOUND", "Unknown Mail route");
|
|
270
|
+
const fields = ["recipient", "subject", "text", "html", "templateId", "attachments", "senderId"];
|
|
271
|
+
if (!isObject(body) || Object.keys(body).some((k) => !fields.includes(k))) return fail(400, "INVALID_MESSAGE", "Invalid message");
|
|
272
|
+
if (!isText(body.recipient, 254) || !body.recipient.includes("@")) return fail(400, "INVALID_MESSAGE", "Invalid recipient");
|
|
273
|
+
const attachments = body.attachments ?? [];
|
|
274
|
+
if (!Array.isArray(attachments) || attachments.length > 4 || attachments.some((a) => !isObject(a) || !isText(a.filename, 128) || !ATTACHMENT_TYPES.includes(a.type) || typeof a.content !== "string")) {
|
|
275
|
+
return fail(400, "INVALID_MESSAGE", "Invalid attachments");
|
|
276
|
+
}
|
|
277
|
+
if (body.senderId !== undefined && !IDENTIFIER.test(body.senderId)) return fail(400, "INVALID_MESSAGE", "Invalid senderId");
|
|
278
|
+
let content;
|
|
279
|
+
if (body.templateId !== undefined) {
|
|
280
|
+
if (!IDENTIFIER.test(body.templateId) || Object.keys(body).some((k) => !["templateId", "recipient", "attachments", "senderId"].includes(k))) {
|
|
281
|
+
return fail(400, "INVALID_MESSAGE", "A template send takes only recipient, attachments and senderId");
|
|
282
|
+
}
|
|
283
|
+
const template = templates.get(body.templateId);
|
|
284
|
+
if (!template) return fail(422, "TEMPLATE_NOT_FOUND", "No such template");
|
|
285
|
+
content = { ...template };
|
|
286
|
+
} else {
|
|
287
|
+
if (!isText(body.subject, 256) || !isText(body.text, 32768) || (body.html !== undefined && body.html !== null && !isText(body.html, 32768, 0))) {
|
|
288
|
+
return fail(400, "INVALID_MESSAGE", "subject and text are required without a template");
|
|
289
|
+
}
|
|
290
|
+
content = { subject: body.subject, text: body.text, html: body.html ?? null };
|
|
291
|
+
}
|
|
292
|
+
const message = { id: await id("msg", key), recipient: body.recipient, ...content, attachments: structuredClone(attachments), senderId: body.senderId ?? null, templateId: body.templateId ?? null };
|
|
293
|
+
sent.push(message);
|
|
294
|
+
return json(202, { id: message.id, state: "queued", requestId: `req_${requests}` });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function me(request) {
|
|
298
|
+
const bearer = /^Bearer (.+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
299
|
+
const user = bearer === undefined ? undefined : users.get(bearer);
|
|
300
|
+
if (!user) return fail(401, "UNAUTHENTICATED", "Sign in again");
|
|
301
|
+
const profile = { id: user.id, metadataState: "not_configured" };
|
|
302
|
+
if (user.email) profile.email = user.email;
|
|
303
|
+
if (user.name) profile.name = user.name;
|
|
304
|
+
return json(200, profile);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function serve(request) {
|
|
308
|
+
requests += 1;
|
|
309
|
+
clock += 1000;
|
|
310
|
+
const url = new URL(request.url);
|
|
311
|
+
const method = request.method;
|
|
312
|
+
const entry = { method, path: url.pathname + url.search, headers: {}, body: null };
|
|
313
|
+
for (const name of LOGGED_HEADERS) if (request.headers.has(name)) entry.headers[name] = request.headers.get(name);
|
|
314
|
+
log.push(entry);
|
|
315
|
+
const response = await route(request, url, method, entry);
|
|
316
|
+
entry.status = response.status;
|
|
317
|
+
return response;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function route(request, url, method, entry) {
|
|
321
|
+
const grantId = /^\/transfer\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
322
|
+
if (grantId) {
|
|
323
|
+
if (method === "PUT") entry.body = { bytes: Number(request.headers.get("content-length") ?? 0) || undefined };
|
|
324
|
+
const response = await transfer(request, grantId);
|
|
325
|
+
if (method === "PUT" && entry.body.bytes === undefined) entry.body = { bytes: (await response.clone().json().catch(() => ({}))).bytes ?? null };
|
|
326
|
+
return response;
|
|
327
|
+
}
|
|
328
|
+
if (url.pathname === "/v1/auth/me") return method === "GET" ? me(request) : fail(405, "INVALID_REQUEST", "Unsupported method");
|
|
329
|
+
const product = url.pathname.startsWith("/_mdrn/v1/database/") ? "database" : url.pathname.startsWith("/v1/storage/") ? "storage" : url.pathname.startsWith("/v1/mail/") ? "mail" : null;
|
|
330
|
+
if (!product) return fail(404, "NOT_FOUND", "Not an MDRN product route");
|
|
331
|
+
const path = url.pathname.slice(product === "database" ? "/_mdrn/v1/database".length : product === "storage" ? "/v1/storage".length : "/v1/mail".length);
|
|
332
|
+
if (request.headers.get("authorization") !== `Bearer ${token[product]}`) return fail(401, "PRODUCT_ACCESS_DENIED", "Product access is unavailable for this request.");
|
|
333
|
+
if (request.headers.get("mdrn-project") !== project || request.headers.get("mdrn-environment") !== environment) return fail(403, "PRODUCT_ACCESS_DENIED", "Product access is unavailable for this request.");
|
|
334
|
+
const key = request.headers.get("idempotency-key") ?? undefined;
|
|
335
|
+
if (method !== "GET" && !IDENTIFIER.test(key ?? "")) return fail(400, "INVALID_REQUEST", "Idempotency-Key is required on every mutation");
|
|
336
|
+
let ifMatch;
|
|
337
|
+
if (request.headers.has("if-match")) {
|
|
338
|
+
ifMatch = Number(request.headers.get("if-match"));
|
|
339
|
+
if (!/^[1-9]\d*$/.test(request.headers.get("if-match"))) return fail(400, "INVALID_REQUEST", "If-Match must be a positive record version");
|
|
340
|
+
}
|
|
341
|
+
let body;
|
|
342
|
+
if (method !== "GET") {
|
|
343
|
+
const text = await request.text();
|
|
344
|
+
if (text) {
|
|
345
|
+
try { body = JSON.parse(text); } catch { return fail(400, "INVALID_REQUEST", "The body is not JSON"); }
|
|
346
|
+
}
|
|
347
|
+
entry.body = body ?? null;
|
|
348
|
+
}
|
|
349
|
+
// Idempotency: the same key sends back the first answer; with another request it is a conflict.
|
|
350
|
+
const replayKey = `${product} ${method} ${path} ${key}`;
|
|
351
|
+
const fingerprint = JSON.stringify([body ?? null, ifMatch ?? null]);
|
|
352
|
+
const replay = method === "GET" ? undefined : replays.get(replayKey);
|
|
353
|
+
if (replay) {
|
|
354
|
+
if (replay.fingerprint !== fingerprint) return fail(409, "CONFLICT", "Idempotency key reused with another request");
|
|
355
|
+
return json(replay.status, replay.body);
|
|
356
|
+
}
|
|
357
|
+
const response = product === "database" ? await database(method, path, url.searchParams, body, key, ifMatch)
|
|
358
|
+
: product === "storage" ? await storage(method, path, url.searchParams, body, key)
|
|
359
|
+
: await mail(method, path, url.searchParams, body, key);
|
|
360
|
+
if (method !== "GET" && response.ok) replays.set(replayKey, { fingerprint, status: response.status, body: response.status === 204 ? undefined : await response.clone().json() });
|
|
361
|
+
return response;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// As a service binding's fetch, a global fetch or a Miniflare handler: a Request (of any
|
|
365
|
+
// realm's Request class) is served as it is; anything else becomes one. Miniflare calls a
|
|
366
|
+
// function binding as (request, miniflare), so only a plain object counts as a RequestInit.
|
|
367
|
+
function fetch(input, init) {
|
|
368
|
+
const isRequest = input !== null && typeof input === "object" && typeof input.url === "string" && typeof input.headers?.get === "function";
|
|
369
|
+
const isInit = init !== null && typeof init === "object" && [Object.prototype, null].includes(Object.getPrototypeOf(init));
|
|
370
|
+
return serve(isRequest && !isInit ? input : new Request(input, init));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
env,
|
|
375
|
+
fetch,
|
|
376
|
+
collections,
|
|
377
|
+
uploads,
|
|
378
|
+
objects,
|
|
379
|
+
operations,
|
|
380
|
+
templates,
|
|
381
|
+
sent,
|
|
382
|
+
log,
|
|
383
|
+
/** Makes `id` a signed-in user and gives the two headers MDRN's Proxy adds to their
|
|
384
|
+
* requests, to put on a Request handed to auth.user or auth.me. */
|
|
385
|
+
signIn(id, email, token = `identity-token-${id}`) {
|
|
386
|
+
users.set(token, { id, email });
|
|
387
|
+
return { "x-mdrn-user-id": id, "x-mdrn-identity-token": token };
|
|
388
|
+
},
|
|
389
|
+
/** Sends bytes to a part grant's URL as a browser or the SDK would, and gives the gateway's
|
|
390
|
+
* answer ({partNumber, bytes, etag}). */
|
|
391
|
+
async putBytes(grantUrl, bytes) {
|
|
392
|
+
const grantId = /\/transfer\/([^/]+)$/.exec(new URL(grantUrl).pathname)?.[1];
|
|
393
|
+
const response = await serve(new Request(grantUrl, { method: "PUT", headers: { "x-mdrn-grant": grantId ?? "" }, body: bytes }));
|
|
394
|
+
const payload = await response.json();
|
|
395
|
+
if (!response.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
|
|
396
|
+
return payload;
|
|
397
|
+
},
|
|
398
|
+
/** Fetches a download grant's URL and gives the bytes. */
|
|
399
|
+
async getBytes(grantUrl) {
|
|
400
|
+
const response = await serve(new Request(grantUrl));
|
|
401
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
402
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
403
|
+
},
|
|
404
|
+
/** Forgets every record, upload, object, message, replay and log entry; templates and
|
|
405
|
+
* signed-in users stay. */
|
|
406
|
+
reset() {
|
|
407
|
+
collections.clear(); uploads.clear(); objects.clear(); grants.clear(); operations.clear();
|
|
408
|
+
sent.length = 0; log.length = 0; replays = new Map(); bumped = new Set();
|
|
409
|
+
clock = Date.UTC(2026, 0, 1); requests = 0;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/** A call that did not succeed. `status` is the HTTP status, 0 when no response was received.
|
|
2
|
+
* `code` is MDRN's error code, or one of the SDK's own: PRODUCT_NOT_ENABLED, UNEXPECTED_REDIRECT,
|
|
3
|
+
* HTTP_ERROR, TIMEOUT, NETWORK_ERROR, UPLOAD_FAILED, FILE_REJECTED. */
|
|
4
|
+
export class MdrnError extends Error {
|
|
5
|
+
constructor(status: number, code: string, message?: string, details?: unknown);
|
|
6
|
+
status: number;
|
|
7
|
+
code: string;
|
|
8
|
+
details: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type Environment = "sandbox" | "production";
|
|
12
|
+
export type Product = "db" | "files" | "mail";
|
|
13
|
+
|
|
14
|
+
/** The options of a write: its idempotency key (a fresh UUID unless given; the same key sends
|
|
15
|
+
* the same change once) and a signal to cancel it. */
|
|
16
|
+
export interface Write {
|
|
17
|
+
idempotencyKey?: string;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ListOptions {
|
|
22
|
+
limit?: number;
|
|
23
|
+
cursor?: string | null;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Page<T> {
|
|
28
|
+
items: T[];
|
|
29
|
+
nextCursor: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Record<T = { [key: string]: unknown }> {
|
|
33
|
+
id: string;
|
|
34
|
+
collection: string;
|
|
35
|
+
version: number;
|
|
36
|
+
data: T;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Database {
|
|
42
|
+
/** Creates a record with `data` (a JSON object) and answers it with its id and version 1. */
|
|
43
|
+
create<T extends object = { [key: string]: unknown }>(collection: string, data: T, options?: Write): Promise<Record<T>>;
|
|
44
|
+
/** The record, or null when there is none with that id. */
|
|
45
|
+
get<T = { [key: string]: unknown }>(collection: string, id: string): Promise<Record<T> | null>;
|
|
46
|
+
list<T = { [key: string]: unknown }>(collection: string, options?: ListOptions): Promise<Page<Record<T>>>;
|
|
47
|
+
/** Merges `patch` into the record's fields (a shallow merge). With `version`, the change is
|
|
48
|
+
* sent once with that If-Match and a 409 CONFLICT is yours; without, the record is read first
|
|
49
|
+
* and a conflict is retried up to three times. */
|
|
50
|
+
update<T = { [key: string]: unknown }>(collection: string, id: string, patch: Partial<T> & object, options?: Write & { version?: number }): Promise<Record<T>>;
|
|
51
|
+
delete(collection: string, id: string, options?: Write & { version?: number }): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type FileState = "verifying" | "ready" | "rejected" | "deleted" | "purging" | "purge_failed" | "purged" | "lost";
|
|
55
|
+
|
|
56
|
+
export interface FileInfo {
|
|
57
|
+
id: string;
|
|
58
|
+
filename: string;
|
|
59
|
+
contentType: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
sha256: string;
|
|
62
|
+
state: FileState;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
deletedAt: string | null;
|
|
65
|
+
purgeAt: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type UploadState = "pending" | "uploading" | "verifying" | "complete" | "aborted" | "expired" | "rejected";
|
|
69
|
+
|
|
70
|
+
export interface Upload {
|
|
71
|
+
id: string;
|
|
72
|
+
objectId: string;
|
|
73
|
+
state: UploadState;
|
|
74
|
+
expiresAt: string;
|
|
75
|
+
operationId: string | null;
|
|
76
|
+
partBytes: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface UploadInput {
|
|
80
|
+
filename: string;
|
|
81
|
+
contentType: string;
|
|
82
|
+
bytes: number;
|
|
83
|
+
/** Hex SHA-256 of the whole file. */
|
|
84
|
+
sha256: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface Grant {
|
|
88
|
+
id: string;
|
|
89
|
+
url: string;
|
|
90
|
+
method: "GET" | "PUT";
|
|
91
|
+
headers: { [name: string]: string };
|
|
92
|
+
expiresAt: string;
|
|
93
|
+
maxBytes: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Part {
|
|
97
|
+
partNumber: number;
|
|
98
|
+
bytes: number;
|
|
99
|
+
etag: string | null;
|
|
100
|
+
state: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Completion {
|
|
104
|
+
id: string;
|
|
105
|
+
filename: string;
|
|
106
|
+
contentType: string;
|
|
107
|
+
bytes: number;
|
|
108
|
+
state: "verifying";
|
|
109
|
+
operationId: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Operation {
|
|
113
|
+
id: string;
|
|
114
|
+
kind: string;
|
|
115
|
+
state: string;
|
|
116
|
+
processedBytes: number;
|
|
117
|
+
processedItems: number;
|
|
118
|
+
attempts: number;
|
|
119
|
+
retryAt: string | null;
|
|
120
|
+
error: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The upload session as the wire has it: for resuming, or for sending bytes from somewhere
|
|
124
|
+
* other than this process (a browser PUTs to the grant URL with the grant's headers). */
|
|
125
|
+
export interface Uploads {
|
|
126
|
+
create(input: UploadInput, options?: Write): Promise<Upload>;
|
|
127
|
+
get(uploadId: string): Promise<Upload | null>;
|
|
128
|
+
list(options?: ListOptions): Promise<Page<Upload>>;
|
|
129
|
+
parts(uploadId: string, options?: ListOptions): Promise<Page<Part>>;
|
|
130
|
+
/** A grant to PUT one part of `size` bytes: 16 MiB (the session's partBytes) each but the last. */
|
|
131
|
+
part(uploadId: string, part: { partNumber: number; size: number }, options?: Write): Promise<Grant>;
|
|
132
|
+
complete(uploadId: string, parts: { partNumber: number; etag: string }[], options?: Write): Promise<Completion>;
|
|
133
|
+
abort(uploadId: string, options?: Write): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface Files {
|
|
137
|
+
/** The whole upload: SHA-256, session, parts, completion and, unless `wait` is false, the
|
|
138
|
+
* verified file (polled for up to a minute). Rejected bytes throw FILE_REJECTED. */
|
|
139
|
+
upload(file: { name: string; type?: string; bytes: Uint8Array | ArrayBuffer }, options?: Write & { wait?: boolean }): Promise<FileInfo>;
|
|
140
|
+
/** The file, or null when there is none with that id. */
|
|
141
|
+
get(id: string): Promise<FileInfo | null>;
|
|
142
|
+
list(options?: ListOptions): Promise<Page<FileInfo>>;
|
|
143
|
+
/** A URL anyone can GET for 15 minutes. */
|
|
144
|
+
downloadUrl(id: string, options?: Write): Promise<{ url: string; expiresAt: string }>;
|
|
145
|
+
delete(id: string, options?: Write): Promise<void>;
|
|
146
|
+
operation(id: string): Promise<Operation>;
|
|
147
|
+
uploads: Uploads;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface Attachment {
|
|
151
|
+
filename: string;
|
|
152
|
+
type: "application/pdf" | "image/png" | "image/jpeg" | "text/plain";
|
|
153
|
+
/** The file, base64 encoded. */
|
|
154
|
+
content: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface Message {
|
|
158
|
+
to: string;
|
|
159
|
+
subject?: string;
|
|
160
|
+
text?: string;
|
|
161
|
+
html?: string;
|
|
162
|
+
attachments?: Attachment[];
|
|
163
|
+
/** A template of the project and environment, which supplies subject, text and html; it
|
|
164
|
+
* cannot be combined with them (a TypeError, before any request). */
|
|
165
|
+
template?: string;
|
|
166
|
+
/** A verified sender domain of the workspace; omit it when there is exactly one. */
|
|
167
|
+
senderId?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface QueuedMessage {
|
|
171
|
+
id: string;
|
|
172
|
+
state: "queued";
|
|
173
|
+
requestId?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface Mail {
|
|
177
|
+
send(message: Message, options?: Write): Promise<QueuedMessage>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface User {
|
|
181
|
+
id: string;
|
|
182
|
+
/** The short-lived identity token of the session, for auth.me. */
|
|
183
|
+
token: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface Profile {
|
|
187
|
+
id: string;
|
|
188
|
+
email?: string;
|
|
189
|
+
name?: string;
|
|
190
|
+
user_metadata?: { [key: string]: unknown };
|
|
191
|
+
app_metadata?: { [key: string]: unknown };
|
|
192
|
+
metadataState: "available" | "not_configured" | "disabled" | "unavailable";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface Auth {
|
|
196
|
+
/** The signed-in visitor of a request MDRN delivered (from the x-mdrn-user-id and
|
|
197
|
+
* x-mdrn-identity-token headers only MDRN can set), or null. */
|
|
198
|
+
user(request: Request): User | null;
|
|
199
|
+
/** The visitor's profile from MDRN Identity, or null when nobody is signed in or the session
|
|
200
|
+
* has ended. */
|
|
201
|
+
me(request: Request): Promise<Profile | null>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface MdrnEnv {
|
|
205
|
+
MDRN_PROJECT_ID: string;
|
|
206
|
+
MDRN_ENVIRONMENT: Environment | string;
|
|
207
|
+
MDRN_DATABASE_URL?: string;
|
|
208
|
+
MDRN_DATABASE_TOKEN?: string;
|
|
209
|
+
MDRN_STORAGE_URL?: string;
|
|
210
|
+
MDRN_STORAGE_TOKEN?: string;
|
|
211
|
+
MDRN_MAIL_URL?: string;
|
|
212
|
+
MDRN_MAIL_TOKEN?: string;
|
|
213
|
+
MDRN_PRODUCTS?: { fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> };
|
|
214
|
+
[other: string]: unknown;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface MdrnOptions {
|
|
218
|
+
/** The release's origin (https://<your app>); each product's URL is derived from it. */
|
|
219
|
+
origin?: string;
|
|
220
|
+
project: string;
|
|
221
|
+
environment: Environment;
|
|
222
|
+
/** The product tokens; a product without one is not enabled. */
|
|
223
|
+
tokens?: { database?: string; storage?: string; mail?: string };
|
|
224
|
+
/** Product URLs in place of the ones derived from `origin` (fromEnv passes MDRN_*_URL). */
|
|
225
|
+
urls?: { database?: string; storage?: string; mail?: string };
|
|
226
|
+
/** Sends every product request (in a Worker, the MDRN_PRODUCTS binding's fetch). */
|
|
227
|
+
fetcher?: typeof fetch;
|
|
228
|
+
/** Sends bytes to and from grant URLs: always a plain fetch, never the binding. */
|
|
229
|
+
transfer?: typeof fetch;
|
|
230
|
+
/** Per-request deadline in milliseconds, 10 000 by default. */
|
|
231
|
+
timeoutMs?: number;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export class Mdrn {
|
|
235
|
+
constructor(options: MdrnOptions);
|
|
236
|
+
/** The client of a deployed release, from what MDRN injected into `env`. Product requests go
|
|
237
|
+
* through the MDRN_PRODUCTS service binding when there is one, else the global fetch. */
|
|
238
|
+
static fromEnv(env: MdrnEnv, options?: { fetcher?: typeof fetch; transfer?: typeof fetch; timeoutMs?: number }): Mdrn;
|
|
239
|
+
readonly project: string;
|
|
240
|
+
readonly environment: Environment;
|
|
241
|
+
/** The products this client can call; a call on any other throws PRODUCT_NOT_ENABLED. */
|
|
242
|
+
readonly enabled: Product[];
|
|
243
|
+
readonly db: Database;
|
|
244
|
+
readonly files: Files;
|
|
245
|
+
readonly mail: Mail;
|
|
246
|
+
readonly auth: Auth;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ScheduledRun {
|
|
250
|
+
schedule: string;
|
|
251
|
+
runId: string;
|
|
252
|
+
scheduledTime: string | null;
|
|
253
|
+
attempt: number;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The schedule run a request carries, or null when it is not one. */
|
|
257
|
+
export function scheduledRun(request: Request): ScheduledRun | null;
|