@getstrata/core 0.5.43 → 0.5.45
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/admin/types.js +1 -0
- 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/bindConnection.js +1 -22
- package/dist/entries/database/boundConnection.js +1 -19
- package/dist/entries/database/connection.js +1 -12
- package/dist/entries/database/defaultConnection.js +1 -0
- package/dist/entries/database/migrations/types.js +1 -0
- package/dist/entries/database/migrations.js +127 -0
- package/dist/entries/database/repositoryConnection.js +1 -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 +158 -0
- package/dist/entries/http/loginThrottleMiddleware.js +46 -0
- package/dist/entries/http/memoryThrottleMiddleware.js +30 -0
- package/dist/entries/http/pagination.js +185 -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 +147 -0
- package/dist/entries/http/scimThrottleMiddleware.js +23 -0
- package/dist/entries/http/securedRouteModelBinding.js +16 -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 +16 -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 +147 -7
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/crypto/nonCryptographicHash.ts
|
|
3
|
+
function nonCryptographicDigest(input) {
|
|
4
|
+
return Bun.hash(input).toString(16);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// ../../src/core/errors/http.ts
|
|
8
|
+
class HttpError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
details;
|
|
11
|
+
constructor(status, message, details) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = new.target.name;
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.details = details;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class BadRequestError extends HttpError {
|
|
20
|
+
constructor(message = "Bad Request", details) {
|
|
21
|
+
super(400, message, details);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class NotFoundError extends HttpError {
|
|
26
|
+
constructor(message = "Not Found", details) {
|
|
27
|
+
super(404, message, details);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class ConflictError extends HttpError {
|
|
32
|
+
constructor(message = "Conflict", details) {
|
|
33
|
+
super(409, message, details);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class UnprocessableEntityError extends HttpError {
|
|
38
|
+
constructor(message = "Unprocessable Entity", details) {
|
|
39
|
+
super(422, message, details);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class ValidationError extends HttpError {
|
|
44
|
+
constructor(message = "Validation failed", details) {
|
|
45
|
+
super(422, message, details);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class ForbiddenError extends HttpError {
|
|
50
|
+
constructor(message = "Forbidden", details) {
|
|
51
|
+
super(403, message, details);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class UnauthorizedError extends HttpError {
|
|
56
|
+
constructor(message = "Unauthorized", details) {
|
|
57
|
+
super(401, message, details);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class PayloadTooLargeError extends HttpError {
|
|
62
|
+
constructor(message = "Payload Too Large", details) {
|
|
63
|
+
super(413, message, details);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class PreconditionFailedError extends HttpError {
|
|
68
|
+
constructor(message = "Precondition Failed", details) {
|
|
69
|
+
super(412, message, details);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ../../src/core/http/etag.ts
|
|
74
|
+
function isEtagEnabled() {
|
|
75
|
+
return (process.env.FEATURE_ETAG ?? "true") !== "false";
|
|
76
|
+
}
|
|
77
|
+
function formatWeakEtag(digest) {
|
|
78
|
+
return `W/"${digest}"`;
|
|
79
|
+
}
|
|
80
|
+
function computeEtagFromJson(data) {
|
|
81
|
+
const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
|
|
82
|
+
return formatWeakEtag(digest);
|
|
83
|
+
}
|
|
84
|
+
function etagFromResource(resource) {
|
|
85
|
+
const version = resource.updated_at ?? resource.created_at ?? "";
|
|
86
|
+
const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
|
|
87
|
+
const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
|
|
88
|
+
return formatWeakEtag(digest);
|
|
89
|
+
}
|
|
90
|
+
function normalizeEtag(value) {
|
|
91
|
+
return value.trim();
|
|
92
|
+
}
|
|
93
|
+
function etagValuesMatch(left, right) {
|
|
94
|
+
return normalizeEtag(left) === normalizeEtag(right);
|
|
95
|
+
}
|
|
96
|
+
function parseEtagList(header) {
|
|
97
|
+
if (!header) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
function ifNoneMatchSatisfied(request, etag) {
|
|
103
|
+
const header = request.headers.get("if-none-match");
|
|
104
|
+
if (!header) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (header.trim() === "*") {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
111
|
+
}
|
|
112
|
+
function ifMatchSatisfied(request, etag) {
|
|
113
|
+
const header = request.headers.get("if-match");
|
|
114
|
+
if (!header) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if (header.trim() === "*") {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
121
|
+
}
|
|
122
|
+
function assertIfMatch(request, etag, options = {}) {
|
|
123
|
+
const header = request.headers.get("if-match");
|
|
124
|
+
if (!header) {
|
|
125
|
+
if (options.required) {
|
|
126
|
+
throw new PreconditionFailedError("If-Match header is required.");
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!ifMatchSatisfied(request, etag)) {
|
|
131
|
+
throw new PreconditionFailedError("Resource ETag does not match If-Match.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function applyEtagHeaders(headers, etag) {
|
|
135
|
+
const next = new Headers(headers);
|
|
136
|
+
next.set("ETag", etag);
|
|
137
|
+
next.set("Cache-Control", "private, must-revalidate");
|
|
138
|
+
next.append("Vary", "Authorization");
|
|
139
|
+
next.append("Vary", "X-Tenant-Id");
|
|
140
|
+
return next;
|
|
141
|
+
}
|
|
142
|
+
function notModifiedResponse(etag) {
|
|
143
|
+
return new Response(null, {
|
|
144
|
+
status: 304,
|
|
145
|
+
headers: applyEtagHeaders(new Headers, etag)
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function applyConditionalGet(request, response, etag) {
|
|
149
|
+
if (!isEtagEnabled()) {
|
|
150
|
+
return response;
|
|
151
|
+
}
|
|
152
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
153
|
+
return notModifiedResponse(etag);
|
|
154
|
+
}
|
|
155
|
+
const headers = applyEtagHeaders(new Headers(response.headers), etag);
|
|
156
|
+
return new Response(response.body, {
|
|
157
|
+
status: response.status,
|
|
158
|
+
statusText: response.statusText,
|
|
159
|
+
headers
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ../../src/core/http/conditionalResponse.ts
|
|
164
|
+
function jsonResponse(data, init = {}) {
|
|
165
|
+
return Response.json(data, {
|
|
166
|
+
status: init.status ?? 200,
|
|
167
|
+
headers: init.headers
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function conditionalJsonResponse(request, data, init = {}) {
|
|
171
|
+
if (!request || !isEtagEnabled()) {
|
|
172
|
+
return jsonResponse(data, init);
|
|
173
|
+
}
|
|
174
|
+
const etag = computeEtagFromJson(data);
|
|
175
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
176
|
+
return notModifiedResponse(etag);
|
|
177
|
+
}
|
|
178
|
+
const response = jsonResponse(data, init);
|
|
179
|
+
const headers = new Headers(response.headers);
|
|
180
|
+
headers.set("ETag", etag);
|
|
181
|
+
headers.set("Cache-Control", "private, must-revalidate");
|
|
182
|
+
headers.append("Vary", "Authorization");
|
|
183
|
+
headers.append("Vary", "X-Tenant-Id");
|
|
184
|
+
return new Response(response.body, {
|
|
185
|
+
status: response.status,
|
|
186
|
+
statusText: response.statusText,
|
|
187
|
+
headers
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
export {
|
|
191
|
+
conditionalJsonResponse
|
|
192
|
+
};
|
|
@@ -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
|
+
};
|