@getstrata/core 0.5.43 → 0.5.44
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/dist/entries/auth/abilityChecker.js +1 -0
- package/dist/entries/auth/membershipMiddleware.js +298 -0
- package/dist/entries/auth/scimAuthMiddleware.js +10 -0
- package/dist/entries/auth/sessionGuard.js +14 -0
- package/dist/entries/database/migrations/types.js +1 -0
- package/dist/entries/database/migrations.js +127 -0
- package/dist/entries/database/seeders/types.js +1 -0
- package/dist/entries/http/conditionalResponse.js +192 -0
- package/dist/entries/http/corsMiddleware.js +54 -0
- package/dist/entries/http/csrfMiddleware.js +236 -0
- package/dist/entries/http/csrfToken.js +3 -0
- package/dist/entries/http/flashMiddleware.js +143 -0
- package/dist/entries/http/formRequest.js +152 -0
- package/dist/entries/http/loginThrottleMiddleware.js +46 -0
- package/dist/entries/http/memoryThrottleMiddleware.js +30 -0
- package/dist/entries/http/requireAbilityMiddleware.js +110 -0
- package/dist/entries/http/requireAuthMiddleware.js +80 -0
- package/dist/entries/http/requireGlobalAdminMiddleware.js +147 -0
- package/dist/entries/http/requireWebAuthMiddleware.js +107 -0
- package/dist/entries/http/route.js +8 -0
- package/dist/entries/http/routeMiddleware.js +32 -0
- package/dist/entries/http/routeModelBinding.js +141 -0
- package/dist/entries/http/scimThrottleMiddleware.js +23 -0
- package/dist/entries/http/securedRouteModelBinding.js +10 -0
- package/dist/entries/http/securityHeadersMiddleware.js +77 -0
- package/dist/entries/http/throttleMiddleware.js +87 -0
- package/dist/entries/http/webErrorResponse.js +14 -0
- package/dist/entries/http/webFormRequest.js +10 -0
- package/dist/entries/logging/requestLoggingMiddleware.js +89 -0
- package/dist/entries/tenant/tenantDatabaseScope.js +10 -0
- package/dist/entries/tracing/tracingMiddleware.js +103 -0
- package/dist/entries/view.js +14 -0
- package/package.json +127 -7
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/config/cors.ts
|
|
3
|
+
var corsConfig = {
|
|
4
|
+
allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean),
|
|
5
|
+
allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
|
6
|
+
allowedHeaders: [
|
|
7
|
+
"Authorization",
|
|
8
|
+
"Content-Type",
|
|
9
|
+
"X-Request-Id",
|
|
10
|
+
"X-Tenant-Id",
|
|
11
|
+
"X-Authenticated-User-Id",
|
|
12
|
+
"X-Authenticated-User-Role",
|
|
13
|
+
"If-Match",
|
|
14
|
+
"If-None-Match"
|
|
15
|
+
],
|
|
16
|
+
maxAgeSeconds: 86400
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ../../src/core/http/corsMiddleware.ts
|
|
20
|
+
function createCorsMiddleware() {
|
|
21
|
+
return async (request, next) => {
|
|
22
|
+
if (request.method === "OPTIONS") {
|
|
23
|
+
return new Response(null, {
|
|
24
|
+
status: 204,
|
|
25
|
+
headers: buildCorsHeaders(request)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const response = await next();
|
|
29
|
+
const headers = new Headers(response.headers);
|
|
30
|
+
for (const [key, value] of buildCorsHeaders(request)) {
|
|
31
|
+
headers.set(key, value);
|
|
32
|
+
}
|
|
33
|
+
return new Response(response.body, {
|
|
34
|
+
status: response.status,
|
|
35
|
+
statusText: response.statusText,
|
|
36
|
+
headers
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function buildCorsHeaders(request) {
|
|
41
|
+
const headers = new Headers;
|
|
42
|
+
const origin = request.headers.get("origin");
|
|
43
|
+
const allowedOrigins = corsConfig.allowedOrigins;
|
|
44
|
+
const allowOrigin = allowedOrigins.includes("*") || origin && allowedOrigins.includes(origin) ? origin ?? "*" : allowedOrigins[0] ?? "*";
|
|
45
|
+
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
46
|
+
headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
|
|
47
|
+
headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
|
|
48
|
+
headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
|
|
49
|
+
headers.set("Vary", "Origin");
|
|
50
|
+
return headers;
|
|
51
|
+
}
|
|
52
|
+
export {
|
|
53
|
+
createCorsMiddleware
|
|
54
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/errors/http.ts
|
|
3
|
+
class HttpError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
details;
|
|
6
|
+
constructor(status, message, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = new.target.name;
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class BadRequestError extends HttpError {
|
|
15
|
+
constructor(message = "Bad Request", details) {
|
|
16
|
+
super(400, message, details);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class NotFoundError extends HttpError {
|
|
21
|
+
constructor(message = "Not Found", details) {
|
|
22
|
+
super(404, message, details);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ConflictError extends HttpError {
|
|
27
|
+
constructor(message = "Conflict", details) {
|
|
28
|
+
super(409, message, details);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class UnprocessableEntityError extends HttpError {
|
|
33
|
+
constructor(message = "Unprocessable Entity", details) {
|
|
34
|
+
super(422, message, details);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ValidationError extends HttpError {
|
|
39
|
+
constructor(message = "Validation failed", details) {
|
|
40
|
+
super(422, message, details);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class ForbiddenError extends HttpError {
|
|
45
|
+
constructor(message = "Forbidden", details) {
|
|
46
|
+
super(403, message, details);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class UnauthorizedError extends HttpError {
|
|
51
|
+
constructor(message = "Unauthorized", details) {
|
|
52
|
+
super(401, message, details);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class PayloadTooLargeError extends HttpError {
|
|
57
|
+
constructor(message = "Payload Too Large", details) {
|
|
58
|
+
super(413, message, details);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class PreconditionFailedError extends HttpError {
|
|
63
|
+
constructor(message = "Precondition Failed", details) {
|
|
64
|
+
super(412, message, details);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ../../src/core/http/csrfToken.ts
|
|
69
|
+
import { timingSafeEqual } from "crypto";
|
|
70
|
+
|
|
71
|
+
// ../../src/core/http/cookies.ts
|
|
72
|
+
function readRequestCookie(request, name) {
|
|
73
|
+
const cookies = request.cookies;
|
|
74
|
+
if (cookies && typeof cookies.get === "function") {
|
|
75
|
+
const value = cookies.get(name);
|
|
76
|
+
if (value) {
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const header = request.headers.get("cookie");
|
|
81
|
+
if (!header) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
for (const part of header.split(";")) {
|
|
85
|
+
const idx = part.indexOf("=");
|
|
86
|
+
if (idx === -1)
|
|
87
|
+
continue;
|
|
88
|
+
const cookieName = part.slice(0, idx).trim();
|
|
89
|
+
if (cookieName !== name)
|
|
90
|
+
continue;
|
|
91
|
+
return decodeURIComponent(part.slice(idx + 1).trim());
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
function readBunRequestCookie(request, name) {
|
|
96
|
+
return request.cookies.get(name) ?? readRequestCookie(request, name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ../../src/core/runtime/asyncContextStore.ts
|
|
100
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
101
|
+
function createAsyncContextStore(key) {
|
|
102
|
+
const symbol = Symbol.for(key);
|
|
103
|
+
const globalRecord = globalThis;
|
|
104
|
+
const existing = globalRecord[symbol];
|
|
105
|
+
if (existing) {
|
|
106
|
+
return existing;
|
|
107
|
+
}
|
|
108
|
+
const store = new AsyncLocalStorage;
|
|
109
|
+
globalRecord[symbol] = store;
|
|
110
|
+
return store;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
114
|
+
var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
|
|
115
|
+
function runWithRequestMeta(meta, callback) {
|
|
116
|
+
return requestMetaContext.run(meta, callback);
|
|
117
|
+
}
|
|
118
|
+
function currentRequestMeta() {
|
|
119
|
+
return requestMetaContext.getStore() ?? {
|
|
120
|
+
ipAddress: null,
|
|
121
|
+
userAgent: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ../../src/core/http/csrfToken.ts
|
|
126
|
+
var CSRF_COOKIE = "workhub_csrf";
|
|
127
|
+
var CSRF_TTL_MS = 60 * 60 * 1000;
|
|
128
|
+
function resolveCsrfSecret() {
|
|
129
|
+
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
|
|
130
|
+
}
|
|
131
|
+
function csrfVerifyOptions() {
|
|
132
|
+
return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
|
|
133
|
+
}
|
|
134
|
+
function tokensMatch(left, right) {
|
|
135
|
+
const leftBuffer = Buffer.from(left);
|
|
136
|
+
const rightBuffer = Buffer.from(right);
|
|
137
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
141
|
+
}
|
|
142
|
+
function createCsrfTokenCookie() {
|
|
143
|
+
const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
|
|
144
|
+
return {
|
|
145
|
+
token,
|
|
146
|
+
cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function resolveCsrfToken(request) {
|
|
150
|
+
const cookieValue = readRequestCookie(request, CSRF_COOKIE);
|
|
151
|
+
if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
|
|
152
|
+
return { token: cookieValue };
|
|
153
|
+
}
|
|
154
|
+
return createCsrfTokenCookie();
|
|
155
|
+
}
|
|
156
|
+
function readSubmittedCsrfToken(request) {
|
|
157
|
+
const headerToken = request.headers.get("x-csrf-token")?.trim();
|
|
158
|
+
if (headerToken) {
|
|
159
|
+
return headerToken;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
async function readSubmittedCsrfTokenFromBody(request) {
|
|
164
|
+
const headerToken = readSubmittedCsrfToken(request);
|
|
165
|
+
if (headerToken) {
|
|
166
|
+
return headerToken;
|
|
167
|
+
}
|
|
168
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
169
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
170
|
+
const formData = await request.clone().formData();
|
|
171
|
+
const field = formData.get("_token");
|
|
172
|
+
if (typeof field === "string" && field.trim().length > 0) {
|
|
173
|
+
return field.trim();
|
|
174
|
+
}
|
|
175
|
+
const legacyField = formData.get("_csrf");
|
|
176
|
+
if (typeof legacyField === "string" && legacyField.trim().length > 0) {
|
|
177
|
+
return legacyField.trim();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
function verifyCsrfToken(request, submittedToken) {
|
|
183
|
+
if (!submittedToken) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const cookieValue = readRequestCookie(request, CSRF_COOKIE);
|
|
187
|
+
if (!cookieValue) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
if (!tokensMatch(submittedToken, cookieValue)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
|
|
194
|
+
}
|
|
195
|
+
function resolveCsrfTokenForRequest(request) {
|
|
196
|
+
const metaToken = currentRequestMeta().csrfToken;
|
|
197
|
+
if (metaToken) {
|
|
198
|
+
return metaToken;
|
|
199
|
+
}
|
|
200
|
+
return resolveCsrfToken(request).token;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ../../src/core/http/csrfMiddleware.ts
|
|
204
|
+
var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
205
|
+
function appendSetCookie(response, cookie) {
|
|
206
|
+
const headers = new Headers(response.headers);
|
|
207
|
+
headers.append("set-cookie", cookie);
|
|
208
|
+
return new Response(response.body, {
|
|
209
|
+
status: response.status,
|
|
210
|
+
statusText: response.statusText,
|
|
211
|
+
headers
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
function createCsrfMiddleware() {
|
|
215
|
+
return async (request, next) => {
|
|
216
|
+
const method = request.method.toUpperCase();
|
|
217
|
+
if (!MUTATING_METHODS.has(method)) {
|
|
218
|
+
const csrf = resolveCsrfToken(request);
|
|
219
|
+
const meta = currentRequestMeta();
|
|
220
|
+
meta.csrfToken = csrf.token;
|
|
221
|
+
const response = await next();
|
|
222
|
+
if (!csrf.cookie) {
|
|
223
|
+
return response;
|
|
224
|
+
}
|
|
225
|
+
return appendSetCookie(response, csrf.cookie);
|
|
226
|
+
}
|
|
227
|
+
const submitted = await readSubmittedCsrfTokenFromBody(request);
|
|
228
|
+
if (!verifyCsrfToken(request, submitted)) {
|
|
229
|
+
throw new ForbiddenError("Invalid or missing CSRF token.");
|
|
230
|
+
}
|
|
231
|
+
return await next();
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export {
|
|
235
|
+
createCsrfMiddleware
|
|
236
|
+
};
|
|
@@ -46,6 +46,9 @@ function createAsyncContextStore(key) {
|
|
|
46
46
|
|
|
47
47
|
// ../../src/core/http/requestMetaContext.ts
|
|
48
48
|
var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
|
|
49
|
+
function runWithRequestMeta(meta, callback) {
|
|
50
|
+
return requestMetaContext.run(meta, callback);
|
|
51
|
+
}
|
|
49
52
|
function currentRequestMeta() {
|
|
50
53
|
return requestMetaContext.getStore() ?? {
|
|
51
54
|
ipAddress: null,
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/flashSession.ts
|
|
3
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
4
|
+
var FLASH_COOKIE = "workhub_flash";
|
|
5
|
+
var FLASH_TTL_MS = 60 * 1000;
|
|
6
|
+
function resolveFlashSecret() {
|
|
7
|
+
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
|
|
8
|
+
}
|
|
9
|
+
function signFlashPayload(payload, issuedAt) {
|
|
10
|
+
const signature = createHmac("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
|
|
11
|
+
return `${payload}.${issuedAt}.${signature}`;
|
|
12
|
+
}
|
|
13
|
+
function readFlashCookie(request) {
|
|
14
|
+
const cookieHeader = request.headers.get("cookie");
|
|
15
|
+
if (!cookieHeader) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
for (const part of cookieHeader.split(";")) {
|
|
19
|
+
const [name, ...rest] = part.trim().split("=");
|
|
20
|
+
if (name === FLASH_COOKIE) {
|
|
21
|
+
return decodeURIComponent(rest.join("="));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
function parseFlashCookie(cookieValue) {
|
|
27
|
+
const parts = cookieValue.split(".");
|
|
28
|
+
if (parts.length < 3) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const signature = parts.pop();
|
|
32
|
+
const issuedAtRaw = parts.pop();
|
|
33
|
+
const payload = parts.join(".");
|
|
34
|
+
if (!signature || !issuedAtRaw || !payload) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const issuedAt = Number.parseInt(issuedAtRaw, 10);
|
|
38
|
+
if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
|
|
42
|
+
if (!expectedSignature) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
46
|
+
const actualBuffer = Buffer.from(signature);
|
|
47
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!timingSafeEqual(expectedBuffer, actualBuffer)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
55
|
+
if (!parsed?.message || typeof parsed.message !== "string") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function createFlashCookie(message) {
|
|
67
|
+
const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
|
|
68
|
+
const issuedAt = Date.now();
|
|
69
|
+
const value = signFlashPayload(payload, issuedAt);
|
|
70
|
+
return `${FLASH_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
|
|
71
|
+
}
|
|
72
|
+
function clearFlashCookie() {
|
|
73
|
+
return `${FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
74
|
+
}
|
|
75
|
+
function pullFlash(request) {
|
|
76
|
+
const cookieValue = readFlashCookie(request);
|
|
77
|
+
if (!cookieValue) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return parseFlashCookie(cookieValue);
|
|
81
|
+
}
|
|
82
|
+
function flashResponse(response, message) {
|
|
83
|
+
const headers = new Headers(response.headers);
|
|
84
|
+
headers.append("set-cookie", createFlashCookie(message));
|
|
85
|
+
return new Response(response.body, {
|
|
86
|
+
status: response.status,
|
|
87
|
+
statusText: response.statusText,
|
|
88
|
+
headers
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function withFlashClear(response) {
|
|
92
|
+
const headers = new Headers(response.headers);
|
|
93
|
+
headers.append("set-cookie", clearFlashCookie());
|
|
94
|
+
return new Response(response.body, {
|
|
95
|
+
status: response.status,
|
|
96
|
+
statusText: response.statusText,
|
|
97
|
+
headers
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ../../src/core/runtime/asyncContextStore.ts
|
|
102
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
103
|
+
function createAsyncContextStore(key) {
|
|
104
|
+
const symbol = Symbol.for(key);
|
|
105
|
+
const globalRecord = globalThis;
|
|
106
|
+
const existing = globalRecord[symbol];
|
|
107
|
+
if (existing) {
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
const store = new AsyncLocalStorage;
|
|
111
|
+
globalRecord[symbol] = store;
|
|
112
|
+
return store;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
116
|
+
var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
|
|
117
|
+
function runWithRequestMeta(meta, callback) {
|
|
118
|
+
return requestMetaContext.run(meta, callback);
|
|
119
|
+
}
|
|
120
|
+
function currentRequestMeta() {
|
|
121
|
+
return requestMetaContext.getStore() ?? {
|
|
122
|
+
ipAddress: null,
|
|
123
|
+
userAgent: null
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ../../src/core/http/flashMiddleware.ts
|
|
128
|
+
function createFlashMiddleware() {
|
|
129
|
+
return async (request, next) => {
|
|
130
|
+
const flash = pullFlash(request);
|
|
131
|
+
const meta = currentRequestMeta();
|
|
132
|
+
return await runWithRequestMeta({ ...meta, request, flash }, async () => {
|
|
133
|
+
const response = await next();
|
|
134
|
+
if (flash) {
|
|
135
|
+
return withFlashClear(response);
|
|
136
|
+
}
|
|
137
|
+
return response;
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export {
|
|
142
|
+
createFlashMiddleware
|
|
143
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/errors/http.ts
|
|
3
|
+
class HttpError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
details;
|
|
6
|
+
constructor(status, message, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = new.target.name;
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.details = details;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class BadRequestError extends HttpError {
|
|
15
|
+
constructor(message = "Bad Request", details) {
|
|
16
|
+
super(400, message, details);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class NotFoundError extends HttpError {
|
|
21
|
+
constructor(message = "Not Found", details) {
|
|
22
|
+
super(404, message, details);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class ConflictError extends HttpError {
|
|
27
|
+
constructor(message = "Conflict", details) {
|
|
28
|
+
super(409, message, details);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class UnprocessableEntityError extends HttpError {
|
|
33
|
+
constructor(message = "Unprocessable Entity", details) {
|
|
34
|
+
super(422, message, details);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class ValidationError extends HttpError {
|
|
39
|
+
constructor(message = "Validation failed", details) {
|
|
40
|
+
super(422, message, details);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class ForbiddenError extends HttpError {
|
|
45
|
+
constructor(message = "Forbidden", details) {
|
|
46
|
+
super(403, message, details);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class UnauthorizedError extends HttpError {
|
|
51
|
+
constructor(message = "Unauthorized", details) {
|
|
52
|
+
super(401, message, details);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class PayloadTooLargeError extends HttpError {
|
|
57
|
+
constructor(message = "Payload Too Large", details) {
|
|
58
|
+
super(413, message, details);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class PreconditionFailedError extends HttpError {
|
|
63
|
+
constructor(message = "Precondition Failed", details) {
|
|
64
|
+
super(412, message, details);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ../../src/core/runtime/asyncContextStore.ts
|
|
69
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
70
|
+
function createAsyncContextStore(key) {
|
|
71
|
+
const symbol = Symbol.for(key);
|
|
72
|
+
const globalRecord = globalThis;
|
|
73
|
+
const existing = globalRecord[symbol];
|
|
74
|
+
if (existing) {
|
|
75
|
+
return existing;
|
|
76
|
+
}
|
|
77
|
+
const store = new AsyncLocalStorage;
|
|
78
|
+
globalRecord[symbol] = store;
|
|
79
|
+
return store;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ../../src/core/auth/authContext.ts
|
|
83
|
+
var authContext = createAsyncContextStore("@getstrata/authContext");
|
|
84
|
+
function runWithAuthUser(user, callback) {
|
|
85
|
+
return authContext.run(user, callback);
|
|
86
|
+
}
|
|
87
|
+
function currentAuthUser() {
|
|
88
|
+
return authContext.getStore() ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ../../src/core/tenant/tenantContext.ts
|
|
92
|
+
var tenantContext = createAsyncContextStore("@getstrata/tenantContext");
|
|
93
|
+
function runWithTenant(tenant, callback) {
|
|
94
|
+
return tenantContext.run(tenant, callback);
|
|
95
|
+
}
|
|
96
|
+
function currentTenant() {
|
|
97
|
+
return tenantContext.getStore() ?? null;
|
|
98
|
+
}
|
|
99
|
+
function currentTenantId() {
|
|
100
|
+
return currentTenant()?.id ?? 1;
|
|
101
|
+
}
|
|
102
|
+
function rateLimitMultiplierForPlan(plan) {
|
|
103
|
+
switch (plan) {
|
|
104
|
+
case "enterprise":
|
|
105
|
+
return 4;
|
|
106
|
+
case "pro":
|
|
107
|
+
return 2;
|
|
108
|
+
default:
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../../src/core/http/validation.ts
|
|
114
|
+
async function parseJsonBody(request, validator) {
|
|
115
|
+
let payload;
|
|
116
|
+
try {
|
|
117
|
+
payload = await request.json();
|
|
118
|
+
} catch {
|
|
119
|
+
throw new BadRequestError("Request body must be valid JSON.");
|
|
120
|
+
}
|
|
121
|
+
return validator(payload);
|
|
122
|
+
}
|
|
123
|
+
function parsePositiveIntParam(value, name = "id") {
|
|
124
|
+
const parsed = Number.parseInt(value, 10);
|
|
125
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
126
|
+
throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
|
|
127
|
+
}
|
|
128
|
+
return parsed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ../../src/core/http/formRequest.ts
|
|
132
|
+
class FormRequest {
|
|
133
|
+
authorize(_request) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
async validate(request) {
|
|
137
|
+
if (!await this.authorize(request)) {
|
|
138
|
+
throw new ForbiddenError;
|
|
139
|
+
}
|
|
140
|
+
return await parseJsonBody(request, (payload) => this.parse(payload));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
class QueryFormRequest {
|
|
145
|
+
validate(request) {
|
|
146
|
+
return this.parseQuery(request);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
QueryFormRequest,
|
|
151
|
+
FormRequest
|
|
152
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/loginThrottleMiddleware.ts
|
|
3
|
+
var {RedisClient } = globalThis.Bun;
|
|
4
|
+
function resolveLoginIdentity(request) {
|
|
5
|
+
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
6
|
+
}
|
|
7
|
+
async function resolveLoginEmail(request) {
|
|
8
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
9
|
+
try {
|
|
10
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
11
|
+
const formData = await request.clone().formData();
|
|
12
|
+
const email = formData.get("email");
|
|
13
|
+
return typeof email === "string" ? email.trim().toLowerCase() : "unknown";
|
|
14
|
+
}
|
|
15
|
+
const payload = await request.clone().json();
|
|
16
|
+
return typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "unknown";
|
|
17
|
+
} catch {
|
|
18
|
+
return "unknown";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function createLoginThrottleMiddleware(options) {
|
|
22
|
+
const client = new RedisClient(options.redisUrl);
|
|
23
|
+
const prefix = options.keyPrefix ?? "workhub:login-throttle:";
|
|
24
|
+
return async (request, next) => {
|
|
25
|
+
const identity = resolveLoginIdentity(request);
|
|
26
|
+
const email = await resolveLoginEmail(request);
|
|
27
|
+
const throttleKey = `${prefix}${identity}:${email}`;
|
|
28
|
+
const attempts = Number(await client.incr(throttleKey));
|
|
29
|
+
if (attempts === 1) {
|
|
30
|
+
await client.expire(throttleKey, options.decaySeconds);
|
|
31
|
+
}
|
|
32
|
+
if (attempts > options.maxAttempts) {
|
|
33
|
+
return Response.json({ error: "Too many login attempts. Try again later." }, {
|
|
34
|
+
status: 429,
|
|
35
|
+
headers: {
|
|
36
|
+
"retry-after": String(options.decaySeconds)
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return await next();
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
resolveLoginIdentity,
|
|
45
|
+
createLoginThrottleMiddleware
|
|
46
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/memoryThrottleMiddleware.ts
|
|
3
|
+
var buckets = new Map;
|
|
4
|
+
function createMemoryThrottleMiddleware(options) {
|
|
5
|
+
const prefix = options.keyPrefix ?? "workhub:memory-throttle:";
|
|
6
|
+
return async (request, next) => {
|
|
7
|
+
const path = new URL(request.url).pathname;
|
|
8
|
+
const identity = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("authorization")?.slice(0, 32) ?? "unknown";
|
|
9
|
+
const key = `${prefix}${identity}:${path}`;
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
const existing = buckets.get(key);
|
|
12
|
+
if (!existing || existing.resetAt <= now) {
|
|
13
|
+
buckets.set(key, { count: 1, resetAt: now + options.decaySeconds * 1000 });
|
|
14
|
+
return await next();
|
|
15
|
+
}
|
|
16
|
+
existing.count += 1;
|
|
17
|
+
if (existing.count > options.maxAttempts) {
|
|
18
|
+
return Response.json({ error: "Too many requests." }, {
|
|
19
|
+
status: 429,
|
|
20
|
+
headers: {
|
|
21
|
+
"retry-after": String(options.decaySeconds)
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return await next();
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
createMemoryThrottleMiddleware
|
|
30
|
+
};
|