@getstrata/core 0.5.51 → 0.5.53
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 +1 -1
- package/dist/entries/auth/accessControl.js +60 -1
- package/dist/entries/auth/membershipMiddleware.js +21 -0
- package/dist/entries/auth/membershipScope.js +336 -1
- package/dist/entries/auth/membershipService.js +304 -1
- package/dist/entries/auth/policy.js +81 -1
- package/dist/entries/database/repositoryQuery.js +655 -0
- package/dist/entries/database/whereBuilder.js +32 -0
- package/dist/entries/facades.js +378 -0
- package/dist/entries/http/formRequest.js +102 -0
- package/dist/entries/http/pagination.js +102 -0
- package/dist/entries/http/routeModelBinding.js +102 -0
- package/dist/entries/http/securedRouteModelBinding.js +102 -0
- package/dist/entries/http/validation.js +145 -0
- package/dist/entries/http/webFormRequest.js +102 -0
- package/package.json +22 -2
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/facades/index.ts
|
|
3
|
+
import { eventBus } from "@getstrata/core/events";
|
|
4
|
+
import {
|
|
5
|
+
resolveApplicationAuth,
|
|
6
|
+
resolveApplicationCache,
|
|
7
|
+
resolveApplicationConfig,
|
|
8
|
+
resolveApplicationEventBus,
|
|
9
|
+
resolveApplicationLogger,
|
|
10
|
+
resolveApplicationPolicyGate,
|
|
11
|
+
resolveApplicationQueue
|
|
12
|
+
} from "@getstrata/core/runtime/applicationRegistry";
|
|
13
|
+
|
|
14
|
+
// ../../src/core/mail/mailer.ts
|
|
15
|
+
function resolveSmtpConfig() {
|
|
16
|
+
const host = process.env.MAIL_HOST?.trim();
|
|
17
|
+
if (!host) {
|
|
18
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_HOST to be set.');
|
|
19
|
+
}
|
|
20
|
+
const from = process.env.MAIL_FROM?.trim();
|
|
21
|
+
if (!from) {
|
|
22
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
23
|
+
}
|
|
24
|
+
const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
|
|
25
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
26
|
+
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
host,
|
|
30
|
+
port,
|
|
31
|
+
from,
|
|
32
|
+
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
33
|
+
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
34
|
+
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function encodeBase64(value) {
|
|
38
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
39
|
+
}
|
|
40
|
+
function parseSmtpResponses(buffer) {
|
|
41
|
+
const responses = [];
|
|
42
|
+
let remainder = buffer;
|
|
43
|
+
while (remainder.includes(`\r
|
|
44
|
+
`)) {
|
|
45
|
+
const index = remainder.indexOf(`\r
|
|
46
|
+
`);
|
|
47
|
+
const line = remainder.slice(0, index);
|
|
48
|
+
remainder = remainder.slice(index + 2);
|
|
49
|
+
if (line.length >= 4 && line[3] === "-") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
responses.push(line);
|
|
53
|
+
}
|
|
54
|
+
return { responses, remainder };
|
|
55
|
+
}
|
|
56
|
+
async function waitForSmtpResponse(readResponse, expectedCodes) {
|
|
57
|
+
const response = await readResponse();
|
|
58
|
+
const code = response.slice(0, 3);
|
|
59
|
+
if (!expectedCodes.includes(code)) {
|
|
60
|
+
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
61
|
+
}
|
|
62
|
+
return response;
|
|
63
|
+
}
|
|
64
|
+
async function openSmtpConnection(config) {
|
|
65
|
+
let buffer = "";
|
|
66
|
+
const waiters = [];
|
|
67
|
+
const readResponse = () => new Promise((resolve, reject) => {
|
|
68
|
+
const parsed = parseSmtpResponses(buffer);
|
|
69
|
+
if (parsed.responses.length > 0) {
|
|
70
|
+
buffer = parsed.remainder;
|
|
71
|
+
resolve(parsed.responses.shift());
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
waiters.push({ resolve, reject });
|
|
75
|
+
});
|
|
76
|
+
const socket = await Bun.connect({
|
|
77
|
+
hostname: config.host,
|
|
78
|
+
port: config.port,
|
|
79
|
+
socket: {
|
|
80
|
+
open() {},
|
|
81
|
+
data(_socket, chunk) {
|
|
82
|
+
buffer += Buffer.from(chunk).toString("utf8");
|
|
83
|
+
const parsed = parseSmtpResponses(buffer);
|
|
84
|
+
buffer = parsed.remainder;
|
|
85
|
+
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
86
|
+
const response = parsed.responses.shift();
|
|
87
|
+
waiters.shift()?.resolve(response);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
error(_socket, error) {
|
|
91
|
+
const pending = waiters.splice(0);
|
|
92
|
+
for (const waiter of pending) {
|
|
93
|
+
waiter.reject(error instanceof Error ? error : new Error(String(error)));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
return { socket, readResponse };
|
|
99
|
+
}
|
|
100
|
+
async function defaultSmtpTransport(config, message) {
|
|
101
|
+
const { socket, readResponse } = await openSmtpConnection(config);
|
|
102
|
+
try {
|
|
103
|
+
await waitForSmtpResponse(readResponse, ["220"]);
|
|
104
|
+
await socket.write(`EHLO workhub.local\r
|
|
105
|
+
`);
|
|
106
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
107
|
+
if (config.username && config.password) {
|
|
108
|
+
await socket.write(`AUTH LOGIN\r
|
|
109
|
+
`);
|
|
110
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
111
|
+
await socket.write(`${encodeBase64(config.username)}\r
|
|
112
|
+
`);
|
|
113
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
114
|
+
await socket.write(`${encodeBase64(config.password)}\r
|
|
115
|
+
`);
|
|
116
|
+
await waitForSmtpResponse(readResponse, ["235"]);
|
|
117
|
+
}
|
|
118
|
+
await socket.write(`MAIL FROM:<${config.from}>\r
|
|
119
|
+
`);
|
|
120
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
121
|
+
await socket.write(`RCPT TO:<${message.to}>\r
|
|
122
|
+
`);
|
|
123
|
+
await waitForSmtpResponse(readResponse, ["250", "251"]);
|
|
124
|
+
await socket.write(`DATA\r
|
|
125
|
+
`);
|
|
126
|
+
await waitForSmtpResponse(readResponse, ["354"]);
|
|
127
|
+
const payload = buildSmtpPayload(config.from, message);
|
|
128
|
+
await socket.write(payload);
|
|
129
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
130
|
+
await socket.write(`QUIT\r
|
|
131
|
+
`);
|
|
132
|
+
await waitForSmtpResponse(readResponse, ["221"]);
|
|
133
|
+
} finally {
|
|
134
|
+
socket.end();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function buildSmtpPayload(from, message) {
|
|
138
|
+
const headers = [
|
|
139
|
+
`From: ${from}`,
|
|
140
|
+
`To: ${message.to}`,
|
|
141
|
+
`Subject: ${message.subject}`,
|
|
142
|
+
"MIME-Version: 1.0"
|
|
143
|
+
];
|
|
144
|
+
if (message.html) {
|
|
145
|
+
const boundary = `strata-${Date.now().toString(36)}`;
|
|
146
|
+
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
147
|
+
const parts = [
|
|
148
|
+
`--${boundary}`,
|
|
149
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
150
|
+
"",
|
|
151
|
+
message.body,
|
|
152
|
+
`--${boundary}`,
|
|
153
|
+
"Content-Type: text/html; charset=utf-8",
|
|
154
|
+
"",
|
|
155
|
+
message.html,
|
|
156
|
+
`--${boundary}--`,
|
|
157
|
+
""
|
|
158
|
+
];
|
|
159
|
+
return [...headers, "", ...parts, ".", ""].join(`\r
|
|
160
|
+
`);
|
|
161
|
+
}
|
|
162
|
+
headers.push("Content-Type: text/plain; charset=utf-8");
|
|
163
|
+
return [...headers, "", message.body, ".", ""].join(`\r
|
|
164
|
+
`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class LogMailDriver {
|
|
168
|
+
async send(message) {
|
|
169
|
+
console.log(JSON.stringify({
|
|
170
|
+
level: "info",
|
|
171
|
+
channel: "mail",
|
|
172
|
+
to: message.to,
|
|
173
|
+
subject: message.subject,
|
|
174
|
+
body: message.body,
|
|
175
|
+
...message.html ? { html: message.html } : {}
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
class SmtpMailDriver {
|
|
181
|
+
config;
|
|
182
|
+
transport;
|
|
183
|
+
constructor(config, transport = defaultSmtpTransport) {
|
|
184
|
+
this.config = config;
|
|
185
|
+
this.transport = transport;
|
|
186
|
+
}
|
|
187
|
+
send(message) {
|
|
188
|
+
return this.transport(this.config, message);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
class Mailer {
|
|
193
|
+
driver;
|
|
194
|
+
constructor(driver) {
|
|
195
|
+
this.driver = driver;
|
|
196
|
+
}
|
|
197
|
+
send(message) {
|
|
198
|
+
return this.driver.send(message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function createMailDriver() {
|
|
202
|
+
const driver = process.env.MAIL_DRIVER ?? "log";
|
|
203
|
+
if (driver === "smtp") {
|
|
204
|
+
return new SmtpMailDriver(resolveSmtpConfig());
|
|
205
|
+
}
|
|
206
|
+
return new LogMailDriver;
|
|
207
|
+
}
|
|
208
|
+
var appMailer = new Mailer(createMailDriver());
|
|
209
|
+
function mailer() {
|
|
210
|
+
return appMailer;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ../../src/core/storage/storage.ts
|
|
214
|
+
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
215
|
+
import { dirname, join } from "path";
|
|
216
|
+
var {S3Client } = globalThis.Bun;
|
|
217
|
+
|
|
218
|
+
class LocalStorageDriver {
|
|
219
|
+
rootDirectory;
|
|
220
|
+
constructor(rootDirectory) {
|
|
221
|
+
this.rootDirectory = rootDirectory;
|
|
222
|
+
}
|
|
223
|
+
resolveRootDirectory() {
|
|
224
|
+
return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
|
|
225
|
+
}
|
|
226
|
+
resolvePath(path) {
|
|
227
|
+
return join(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
|
|
228
|
+
}
|
|
229
|
+
async put(path, contents) {
|
|
230
|
+
const absolutePath = this.resolvePath(path);
|
|
231
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
232
|
+
await writeFile(absolutePath, contents);
|
|
233
|
+
return path;
|
|
234
|
+
}
|
|
235
|
+
async get(path) {
|
|
236
|
+
try {
|
|
237
|
+
return await readFile(this.resolvePath(path));
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async delete(path) {
|
|
243
|
+
try {
|
|
244
|
+
await unlink(this.resolvePath(path));
|
|
245
|
+
return true;
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
class S3StorageDriver {
|
|
253
|
+
client;
|
|
254
|
+
constructor(client) {
|
|
255
|
+
this.client = client;
|
|
256
|
+
}
|
|
257
|
+
async put(path, contents) {
|
|
258
|
+
await this.client.write(path.replace(/^\/+/, ""), contents);
|
|
259
|
+
return path;
|
|
260
|
+
}
|
|
261
|
+
async get(path) {
|
|
262
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
263
|
+
const file = this.client.file(normalizedPath);
|
|
264
|
+
if (!await file.exists()) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
268
|
+
}
|
|
269
|
+
async delete(path) {
|
|
270
|
+
try {
|
|
271
|
+
await this.client.unlink(path.replace(/^\/+/, ""));
|
|
272
|
+
return true;
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
class StorageManager {
|
|
280
|
+
driver;
|
|
281
|
+
constructor(driver) {
|
|
282
|
+
this.driver = driver;
|
|
283
|
+
}
|
|
284
|
+
put(path, contents) {
|
|
285
|
+
return this.driver.put(path, contents);
|
|
286
|
+
}
|
|
287
|
+
get(path) {
|
|
288
|
+
return this.driver.get(path);
|
|
289
|
+
}
|
|
290
|
+
delete(path) {
|
|
291
|
+
return this.driver.delete(path);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function resolveS3Config() {
|
|
295
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
|
|
296
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
|
297
|
+
const bucket = process.env.AWS_BUCKET?.trim();
|
|
298
|
+
if (!accessKeyId || !secretAccessKey || !bucket) {
|
|
299
|
+
throw new Error('STORAGE_DRIVER="s3" requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET.');
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
accessKeyId,
|
|
303
|
+
secretAccessKey,
|
|
304
|
+
bucket,
|
|
305
|
+
...process.env.AWS_REGION?.trim() ? { region: process.env.AWS_REGION.trim() } : {},
|
|
306
|
+
...process.env.AWS_ENDPOINT?.trim() ? { endpoint: process.env.AWS_ENDPOINT.trim() } : {}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function createS3Client(config = resolveS3Config()) {
|
|
310
|
+
return new S3Client({
|
|
311
|
+
accessKeyId: config.accessKeyId,
|
|
312
|
+
secretAccessKey: config.secretAccessKey,
|
|
313
|
+
bucket: config.bucket,
|
|
314
|
+
...config.region ? { region: config.region } : {},
|
|
315
|
+
...config.endpoint ? { endpoint: config.endpoint } : {}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function createStorageDriver() {
|
|
319
|
+
const driver = process.env.STORAGE_DRIVER ?? "local";
|
|
320
|
+
if (driver === "s3") {
|
|
321
|
+
return new S3StorageDriver(createS3Client());
|
|
322
|
+
}
|
|
323
|
+
return new LocalStorageDriver;
|
|
324
|
+
}
|
|
325
|
+
var defaultStorage = { current: null };
|
|
326
|
+
function storage() {
|
|
327
|
+
if (!defaultStorage.current) {
|
|
328
|
+
defaultStorage.current = new StorageManager(createStorageDriver());
|
|
329
|
+
}
|
|
330
|
+
return defaultStorage.current;
|
|
331
|
+
}
|
|
332
|
+
function resetDefaultStorage() {
|
|
333
|
+
defaultStorage.current = null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ../../src/core/facades/index.ts
|
|
337
|
+
function cache() {
|
|
338
|
+
return resolveApplicationCache();
|
|
339
|
+
}
|
|
340
|
+
function auth() {
|
|
341
|
+
return resolveApplicationAuth();
|
|
342
|
+
}
|
|
343
|
+
function policyGate() {
|
|
344
|
+
return resolveApplicationPolicyGate();
|
|
345
|
+
}
|
|
346
|
+
function queue() {
|
|
347
|
+
return resolveApplicationQueue();
|
|
348
|
+
}
|
|
349
|
+
function events() {
|
|
350
|
+
try {
|
|
351
|
+
return resolveApplicationEventBus();
|
|
352
|
+
} catch {
|
|
353
|
+
return eventBus;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function config(key) {
|
|
357
|
+
return resolveApplicationConfig().get(key);
|
|
358
|
+
}
|
|
359
|
+
function log() {
|
|
360
|
+
return resolveApplicationLogger();
|
|
361
|
+
}
|
|
362
|
+
function mail() {
|
|
363
|
+
return mailer();
|
|
364
|
+
}
|
|
365
|
+
function storageFacade() {
|
|
366
|
+
return storage();
|
|
367
|
+
}
|
|
368
|
+
export {
|
|
369
|
+
storageFacade as storage,
|
|
370
|
+
queue,
|
|
371
|
+
policyGate,
|
|
372
|
+
mail,
|
|
373
|
+
log,
|
|
374
|
+
events,
|
|
375
|
+
config,
|
|
376
|
+
cache,
|
|
377
|
+
auth
|
|
378
|
+
};
|
|
@@ -6,12 +6,65 @@ import { ForbiddenError } from "@getstrata/core/errors/http";
|
|
|
6
6
|
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
7
7
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
8
8
|
import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
|
|
9
|
+
function buildRequestCacheKey(fallbackPath, request) {
|
|
10
|
+
if (!request) {
|
|
11
|
+
return fallbackPath;
|
|
12
|
+
}
|
|
13
|
+
const url = new URL(request.url);
|
|
14
|
+
const user = currentAuthUser();
|
|
15
|
+
const authScope = user ? `u:${user.id}` : "guest";
|
|
16
|
+
const tenantScope = `t:${currentTenantId()}`;
|
|
17
|
+
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
18
|
+
}
|
|
9
19
|
function getQueryParams(request) {
|
|
10
20
|
if (!request) {
|
|
11
21
|
return new URLSearchParams;
|
|
12
22
|
}
|
|
13
23
|
return new URL(request.url).searchParams;
|
|
14
24
|
}
|
|
25
|
+
function parseOptionalPositiveIntQueryParam(params, name) {
|
|
26
|
+
const value = params.get(name);
|
|
27
|
+
if (value === null || value.trim() === "") {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const parsed = Number.parseInt(value, 10);
|
|
31
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
32
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
|
|
33
|
+
}
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
function parseOptionalBooleanQueryParam(params, name) {
|
|
37
|
+
const value = params.get(name);
|
|
38
|
+
if (value === null || value.trim() === "") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
switch (value.toLowerCase()) {
|
|
42
|
+
case "true":
|
|
43
|
+
case "1":
|
|
44
|
+
return true;
|
|
45
|
+
case "false":
|
|
46
|
+
case "0":
|
|
47
|
+
return false;
|
|
48
|
+
default:
|
|
49
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function parseOptionalEnumQueryParam(params, name, allowedValues) {
|
|
53
|
+
const value = params.get(name);
|
|
54
|
+
if (value === null || value.trim() === "") {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!allowedValues.includes(value)) {
|
|
58
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected one of: ${allowedValues.join(", ")}.`);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function expectObject(value, label = "request body") {
|
|
63
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
64
|
+
throw new BadRequestError(`${label} must be a JSON object.`);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
15
68
|
async function parseJsonBody(request, validator) {
|
|
16
69
|
let payload;
|
|
17
70
|
try {
|
|
@@ -21,6 +74,55 @@ async function parseJsonBody(request, validator) {
|
|
|
21
74
|
}
|
|
22
75
|
return validator(payload);
|
|
23
76
|
}
|
|
77
|
+
function readRequiredString(payload, field, options = {}) {
|
|
78
|
+
const value = payload[field];
|
|
79
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
80
|
+
throw new BadRequestError(`"${field}" is required and must be a string.`);
|
|
81
|
+
}
|
|
82
|
+
const trimmed = value.trim();
|
|
83
|
+
if (options.minLength !== undefined && trimmed.length < options.minLength) {
|
|
84
|
+
throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
|
|
85
|
+
}
|
|
86
|
+
if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
|
|
87
|
+
throw new BadRequestError(`"${field}" must be at most ${options.maxLength} characters.`);
|
|
88
|
+
}
|
|
89
|
+
if (options.pattern && !options.pattern.test(trimmed)) {
|
|
90
|
+
throw new BadRequestError(`"${field}" has an invalid format.`);
|
|
91
|
+
}
|
|
92
|
+
return trimmed;
|
|
93
|
+
}
|
|
94
|
+
function readOptionalString(payload, field, options = {}) {
|
|
95
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
return readRequiredString(payload, field, options);
|
|
99
|
+
}
|
|
100
|
+
function readRequiredEnum(payload, field, allowedValues) {
|
|
101
|
+
const value = readRequiredString(payload, field);
|
|
102
|
+
if (!allowedValues.includes(value)) {
|
|
103
|
+
throw new BadRequestError(`"${field}" must be one of: ${allowedValues.join(", ")}.`);
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
function readOptionalEnum(payload, field, allowedValues) {
|
|
108
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
return readRequiredEnum(payload, field, allowedValues);
|
|
112
|
+
}
|
|
113
|
+
function readRequiredPositiveInt(payload, field) {
|
|
114
|
+
const value = payload[field];
|
|
115
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
116
|
+
throw new BadRequestError(`"${field}" is required and must be a positive integer.`);
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
function readOptionalPositiveInt(payload, field) {
|
|
121
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
return readRequiredPositiveInt(payload, field);
|
|
125
|
+
}
|
|
24
126
|
function parsePositiveIntParam(value, name = "id") {
|
|
25
127
|
const parsed = Number.parseInt(value, 10);
|
|
26
128
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
@@ -17,12 +17,65 @@ function buildPaginationMeta(input) {
|
|
|
17
17
|
import { currentAuthUser } from "@getstrata/core/auth/authContext";
|
|
18
18
|
import { BadRequestError } from "@getstrata/core/errors/http";
|
|
19
19
|
import { currentTenantId } from "@getstrata/core/tenant/tenantContext";
|
|
20
|
+
function buildRequestCacheKey(fallbackPath, request) {
|
|
21
|
+
if (!request) {
|
|
22
|
+
return fallbackPath;
|
|
23
|
+
}
|
|
24
|
+
const url = new URL(request.url);
|
|
25
|
+
const user = currentAuthUser();
|
|
26
|
+
const authScope = user ? `u:${user.id}` : "guest";
|
|
27
|
+
const tenantScope = `t:${currentTenantId()}`;
|
|
28
|
+
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
29
|
+
}
|
|
20
30
|
function getQueryParams(request) {
|
|
21
31
|
if (!request) {
|
|
22
32
|
return new URLSearchParams;
|
|
23
33
|
}
|
|
24
34
|
return new URL(request.url).searchParams;
|
|
25
35
|
}
|
|
36
|
+
function parseOptionalPositiveIntQueryParam(params, name) {
|
|
37
|
+
const value = params.get(name);
|
|
38
|
+
if (value === null || value.trim() === "") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const parsed = Number.parseInt(value, 10);
|
|
42
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
43
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
|
|
44
|
+
}
|
|
45
|
+
return parsed;
|
|
46
|
+
}
|
|
47
|
+
function parseOptionalBooleanQueryParam(params, name) {
|
|
48
|
+
const value = params.get(name);
|
|
49
|
+
if (value === null || value.trim() === "") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
switch (value.toLowerCase()) {
|
|
53
|
+
case "true":
|
|
54
|
+
case "1":
|
|
55
|
+
return true;
|
|
56
|
+
case "false":
|
|
57
|
+
case "0":
|
|
58
|
+
return false;
|
|
59
|
+
default:
|
|
60
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function parseOptionalEnumQueryParam(params, name, allowedValues) {
|
|
64
|
+
const value = params.get(name);
|
|
65
|
+
if (value === null || value.trim() === "") {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!allowedValues.includes(value)) {
|
|
69
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected one of: ${allowedValues.join(", ")}.`);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function expectObject(value, label = "request body") {
|
|
74
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
75
|
+
throw new BadRequestError(`${label} must be a JSON object.`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
26
79
|
async function parseJsonBody(request, validator) {
|
|
27
80
|
let payload;
|
|
28
81
|
try {
|
|
@@ -32,6 +85,55 @@ async function parseJsonBody(request, validator) {
|
|
|
32
85
|
}
|
|
33
86
|
return validator(payload);
|
|
34
87
|
}
|
|
88
|
+
function readRequiredString(payload, field, options = {}) {
|
|
89
|
+
const value = payload[field];
|
|
90
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
91
|
+
throw new BadRequestError(`"${field}" is required and must be a string.`);
|
|
92
|
+
}
|
|
93
|
+
const trimmed = value.trim();
|
|
94
|
+
if (options.minLength !== undefined && trimmed.length < options.minLength) {
|
|
95
|
+
throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
|
|
96
|
+
}
|
|
97
|
+
if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
|
|
98
|
+
throw new BadRequestError(`"${field}" must be at most ${options.maxLength} characters.`);
|
|
99
|
+
}
|
|
100
|
+
if (options.pattern && !options.pattern.test(trimmed)) {
|
|
101
|
+
throw new BadRequestError(`"${field}" has an invalid format.`);
|
|
102
|
+
}
|
|
103
|
+
return trimmed;
|
|
104
|
+
}
|
|
105
|
+
function readOptionalString(payload, field, options = {}) {
|
|
106
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
return readRequiredString(payload, field, options);
|
|
110
|
+
}
|
|
111
|
+
function readRequiredEnum(payload, field, allowedValues) {
|
|
112
|
+
const value = readRequiredString(payload, field);
|
|
113
|
+
if (!allowedValues.includes(value)) {
|
|
114
|
+
throw new BadRequestError(`"${field}" must be one of: ${allowedValues.join(", ")}.`);
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function readOptionalEnum(payload, field, allowedValues) {
|
|
119
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
return readRequiredEnum(payload, field, allowedValues);
|
|
123
|
+
}
|
|
124
|
+
function readRequiredPositiveInt(payload, field) {
|
|
125
|
+
const value = payload[field];
|
|
126
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
127
|
+
throw new BadRequestError(`"${field}" is required and must be a positive integer.`);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function readOptionalPositiveInt(payload, field) {
|
|
132
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
return readRequiredPositiveInt(payload, field);
|
|
136
|
+
}
|
|
35
137
|
function parsePositiveIntParam(value, name = "id") {
|
|
36
138
|
const parsed = Number.parseInt(value, 10);
|
|
37
139
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|