@getstrata/core 0.4.0 → 0.5.0
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/bootstrap/httpKernel.d.ts +2 -0
- package/dist/config/queue.d.ts +8 -0
- package/dist/config/rateLimit.d.ts +2 -1
- package/dist/core/database/baseRepository.d.ts +16 -4
- package/dist/core/database/index.d.ts +10 -4
- package/dist/core/database/migrations/advisoryLock.d.ts +6 -0
- package/dist/core/database/migrations/runner.d.ts +8 -2
- package/dist/core/database/migrations/types.d.ts +1 -0
- package/dist/core/database/model.d.ts +46 -17
- package/dist/core/database/query.d.ts +25 -14
- package/dist/core/database/relationships.d.ts +32 -2
- package/dist/core/database/repositoryQuery.d.ts +16 -1
- package/dist/core/database/schema/blueprint.d.ts +47 -0
- package/dist/core/database/schema/columnDefinition.d.ts +36 -0
- package/dist/core/database/schema/driver.d.ts +8 -0
- package/dist/core/database/schema/errors.d.ts +4 -0
- package/dist/core/database/schema/grammars/compileStatements.d.ts +8 -0
- package/dist/core/database/schema/grammars/createGrammar.d.ts +4 -0
- package/dist/core/database/schema/grammars/grammar.d.ts +10 -0
- package/dist/core/database/schema/grammars/index.d.ts +7 -0
- package/dist/core/database/schema/grammars/mysqlGrammar.d.ts +2 -0
- package/dist/core/database/schema/grammars/postgresGrammar.d.ts +2 -0
- package/dist/core/database/schema/grammars/sqliteGrammar.d.ts +2 -0
- package/dist/core/database/schema/index.d.ts +12 -0
- package/dist/core/database/schema/schema.d.ts +21 -0
- package/dist/core/database/seeders/runner.d.ts +6 -0
- package/dist/core/database/seeders/types.d.ts +8 -0
- package/dist/core/database/types.d.ts +39 -2
- package/dist/core/database/whereBuilder.d.ts +17 -0
- package/dist/core/http/index.d.ts +1 -1
- package/dist/core/http/securedRouteModelBinding.d.ts +2 -1
- package/dist/core/lifecycle/gracefulShutdown.d.ts +6 -0
- package/dist/core/pagination/index.d.ts +11 -1
- package/dist/core/queue/failedJobRepository.d.ts +6 -0
- package/dist/core/queue/failedJobService.d.ts +15 -0
- package/dist/core/queue/failedJobTable.d.ts +3 -0
- package/dist/core/queue/jobRegistry.d.ts +14 -0
- package/dist/core/queue/jobRunner.d.ts +9 -0
- package/dist/core/queue/publicQueue.d.ts +15 -0
- package/dist/core/queue/redisQueue.d.ts +29 -0
- package/dist/core/queue/resilientQueue.d.ts +9 -0
- package/dist/core/queue/types.d.ts +8 -0
- package/dist/core/scheduler/schedule.d.ts +15 -0
- package/dist/entries/auth/accessControl.js +113 -0
- package/dist/entries/auth/authContext.js +15 -0
- package/dist/entries/auth/guard.js +2870 -0
- package/dist/entries/auth/membershipContext.js +276 -0
- package/dist/entries/auth/membershipScope.js +390 -0
- package/dist/entries/auth/membershipService.js +477 -0
- package/dist/entries/auth/oauth/oidcProvider.js +49 -0
- package/dist/entries/auth/oauth/providers.js +71 -0
- package/dist/entries/auth/oauth/samlProvider.js +26 -0
- package/dist/entries/auth/oauth/types.js +1 -0
- package/dist/entries/auth/password.js +15 -0
- package/dist/entries/auth/policy.js +134 -0
- package/dist/entries/auth/sessionCookie.js +75 -0
- package/dist/entries/auth/tokenHash.js +17 -0
- package/dist/entries/cache/tags.js +13 -0
- package/dist/entries/crypto/fieldEncryption.js +93 -0
- package/dist/entries/crypto/mfaSecret.js +105 -0
- package/dist/entries/database/factory.js +17 -0
- package/dist/entries/database/seeders.js +30 -0
- package/dist/entries/database/types.js +1 -0
- package/dist/entries/database.js +2218 -0
- package/dist/entries/errors/http.js +78 -0
- package/dist/entries/http/contentNegotiation.js +26 -0
- package/dist/entries/http/csrfToken.js +147 -0
- package/dist/entries/http/etag.js +170 -0
- package/dist/entries/http/flashSession.js +106 -0
- package/dist/entries/http/middleware.js +68 -0
- package/dist/entries/http/parseFormBody.js +88 -0
- package/dist/entries/http/requestMetaContext.js +18 -0
- package/dist/entries/http/resources.js +19 -0
- package/dist/entries/http/webErrorResponse.js +3143 -0
- package/dist/entries/http/webFormRequest.js +293 -0
- package/dist/entries/http.js +3924 -0
- package/dist/entries/jobs/dispatchWebhookJob.js +342 -0
- package/dist/entries/lifecycle/gracefulShutdown.js +50 -0
- package/dist/entries/metrics/prometheus.js +70 -0
- package/dist/entries/pagination.js +14 -0
- package/dist/entries/queue/createAppQueue.js +2848 -0
- package/dist/entries/queue/failedJobService.js +38 -0
- package/dist/entries/queue/jobRegistry.js +32 -0
- package/dist/entries/queue/jobRunner.js +75 -0
- package/dist/entries/queue/publicQueue.js +2476 -0
- package/dist/entries/queue/queueMetrics.js +2898 -0
- package/dist/entries/queue/types.js +1 -0
- package/dist/entries/security/oauthState.js +75 -0
- package/dist/entries/security/publicReads.js +33 -0
- package/dist/entries/security/safeUrl.js +143 -0
- package/dist/entries/security/securityEvents.js +41 -0
- package/dist/entries/security/stripeWebhook.js +115 -0
- package/dist/entries/security/tokenExpiry.js +16 -0
- package/dist/entries/security/totp.js +51 -0
- package/dist/entries/storage/storage.js +123 -0
- package/dist/entries/tenant/tenantContext.js +30 -0
- package/dist/entries/tenant/tenantMiddleware.js +312 -0
- package/dist/entries/tracing/traceContext.js +15 -0
- package/dist/entries/validation/rules.js +232 -0
- package/dist/entries/view.js +3072 -0
- package/dist/framework/public-api.d.ts +65 -38
- package/dist/index.js +3198 -308
- package/dist/modules/user/apiTokenRepository.d.ts +1 -1
- package/dist/modules/user/apiTokenTable.d.ts +1 -1
- package/dist/modules/user/authService.d.ts +1 -1
- package/dist/modules/user/notificationRepository.d.ts +1 -1
- package/dist/modules/user/notificationService.d.ts +1 -1
- package/dist/modules/user/notificationTable.d.ts +1 -1
- package/dist/modules/user/oauthIdentityRepository.d.ts +2 -2
- package/dist/modules/user/provider.d.ts +1 -1
- package/dist/modules/user/repository.d.ts +1 -1
- package/dist/modules/user/table.d.ts +1 -1
- package/dist/modules/user/tokenService.d.ts +1 -1
- package/package.json +289 -3
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
export {
|
|
68
|
+
ValidationError,
|
|
69
|
+
UnprocessableEntityError,
|
|
70
|
+
UnauthorizedError,
|
|
71
|
+
PreconditionFailedError,
|
|
72
|
+
PayloadTooLargeError,
|
|
73
|
+
NotFoundError,
|
|
74
|
+
HttpError,
|
|
75
|
+
ForbiddenError,
|
|
76
|
+
ConflictError,
|
|
77
|
+
BadRequestError
|
|
78
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/contentNegotiation.ts
|
|
3
|
+
function requestPrefersJson(request) {
|
|
4
|
+
if (!request) {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
if (request.headers.get("HX-Request") === "true") {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
11
|
+
if (accept.includes("text/html")) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (accept.includes("application/json")) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
18
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const pathname = new URL(request.url).pathname;
|
|
22
|
+
return pathname.startsWith("/api/");
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
requestPrefersJson
|
|
26
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/csrfToken.ts
|
|
3
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
4
|
+
|
|
5
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
6
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
7
|
+
var requestMetaContext = new AsyncLocalStorage;
|
|
8
|
+
function runWithRequestMeta(meta, callback) {
|
|
9
|
+
return requestMetaContext.run(meta, callback);
|
|
10
|
+
}
|
|
11
|
+
function currentRequestMeta() {
|
|
12
|
+
return requestMetaContext.getStore() ?? {
|
|
13
|
+
ipAddress: null,
|
|
14
|
+
userAgent: null
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ../../src/core/http/csrfToken.ts
|
|
19
|
+
var CSRF_COOKIE = "workhub_csrf";
|
|
20
|
+
var CSRF_TTL_MS = 60 * 60 * 1000;
|
|
21
|
+
function resolveCsrfSecret() {
|
|
22
|
+
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
|
|
23
|
+
}
|
|
24
|
+
function signCsrfToken(token, issuedAt) {
|
|
25
|
+
const payload = `${token}.${issuedAt}`;
|
|
26
|
+
const signature = createHmac("sha256", resolveCsrfSecret()).update(payload).digest("hex");
|
|
27
|
+
return `${payload}.${signature}`;
|
|
28
|
+
}
|
|
29
|
+
function readCsrfCookie(request) {
|
|
30
|
+
const cookieHeader = request.headers.get("cookie");
|
|
31
|
+
if (!cookieHeader) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
for (const part of cookieHeader.split(";")) {
|
|
35
|
+
const [name, ...rest] = part.trim().split("=");
|
|
36
|
+
if (name === CSRF_COOKIE) {
|
|
37
|
+
return decodeURIComponent(rest.join("="));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
function parseSignedCsrfValue(cookieValue) {
|
|
43
|
+
const parts = cookieValue.split(".");
|
|
44
|
+
if (parts.length !== 3) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const [token, issuedAtRaw, cookieSignature] = parts;
|
|
48
|
+
if (!token || !issuedAtRaw || !cookieSignature) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const issuedAt = Number.parseInt(issuedAtRaw, 10);
|
|
52
|
+
if (!Number.isFinite(issuedAt)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() - issuedAt > CSRF_TTL_MS) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const expectedSignature = signCsrfToken(token, issuedAt).split(".").pop();
|
|
59
|
+
if (!expectedSignature) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
63
|
+
const actualBuffer = Buffer.from(cookieSignature);
|
|
64
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!timingSafeEqual(expectedBuffer, actualBuffer)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return { token, issuedAt };
|
|
71
|
+
}
|
|
72
|
+
function createCsrfTokenCookie() {
|
|
73
|
+
const token = randomBytes(24).toString("hex");
|
|
74
|
+
const issuedAt = Date.now();
|
|
75
|
+
const value = signCsrfToken(token, issuedAt);
|
|
76
|
+
return {
|
|
77
|
+
token,
|
|
78
|
+
cookie: `${CSRF_COOKIE}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=3600`
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function resolveCsrfToken(request) {
|
|
82
|
+
const cookieValue = readCsrfCookie(request);
|
|
83
|
+
if (cookieValue) {
|
|
84
|
+
const parsed = parseSignedCsrfValue(cookieValue);
|
|
85
|
+
if (parsed) {
|
|
86
|
+
return { token: parsed.token };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return createCsrfTokenCookie();
|
|
90
|
+
}
|
|
91
|
+
function readSubmittedCsrfToken(request) {
|
|
92
|
+
const headerToken = request.headers.get("x-csrf-token")?.trim();
|
|
93
|
+
if (headerToken) {
|
|
94
|
+
return headerToken;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
async function readSubmittedCsrfTokenFromBody(request) {
|
|
99
|
+
const headerToken = readSubmittedCsrfToken(request);
|
|
100
|
+
if (headerToken) {
|
|
101
|
+
return headerToken;
|
|
102
|
+
}
|
|
103
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
104
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
105
|
+
const formData = await request.clone().formData();
|
|
106
|
+
const field = formData.get("_token");
|
|
107
|
+
if (typeof field === "string" && field.trim().length > 0) {
|
|
108
|
+
return field.trim();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
function verifyCsrfToken(request, submittedToken) {
|
|
114
|
+
if (!submittedToken) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const cookieValue = readCsrfCookie(request);
|
|
118
|
+
if (!cookieValue) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const parsed = parseSignedCsrfValue(cookieValue);
|
|
122
|
+
if (!parsed) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const submittedBuffer = Buffer.from(submittedToken);
|
|
126
|
+
const expectedBuffer = Buffer.from(parsed.token);
|
|
127
|
+
if (submittedBuffer.length !== expectedBuffer.length) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return timingSafeEqual(submittedBuffer, expectedBuffer);
|
|
131
|
+
}
|
|
132
|
+
function resolveCsrfTokenForRequest(request) {
|
|
133
|
+
const metaToken = currentRequestMeta().csrfToken;
|
|
134
|
+
if (metaToken) {
|
|
135
|
+
return metaToken;
|
|
136
|
+
}
|
|
137
|
+
return resolveCsrfToken(request).token;
|
|
138
|
+
}
|
|
139
|
+
export {
|
|
140
|
+
verifyCsrfToken,
|
|
141
|
+
resolveCsrfTokenForRequest,
|
|
142
|
+
resolveCsrfToken,
|
|
143
|
+
readSubmittedCsrfTokenFromBody,
|
|
144
|
+
readSubmittedCsrfToken,
|
|
145
|
+
createCsrfTokenCookie,
|
|
146
|
+
CSRF_COOKIE
|
|
147
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/etag.ts
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
|
|
5
|
+
// ../../src/core/errors/http.ts
|
|
6
|
+
class HttpError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
details;
|
|
9
|
+
constructor(status, message, details) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = new.target.name;
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class BadRequestError extends HttpError {
|
|
18
|
+
constructor(message = "Bad Request", details) {
|
|
19
|
+
super(400, message, details);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class NotFoundError extends HttpError {
|
|
24
|
+
constructor(message = "Not Found", details) {
|
|
25
|
+
super(404, message, details);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class ConflictError extends HttpError {
|
|
30
|
+
constructor(message = "Conflict", details) {
|
|
31
|
+
super(409, message, details);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class UnprocessableEntityError extends HttpError {
|
|
36
|
+
constructor(message = "Unprocessable Entity", details) {
|
|
37
|
+
super(422, message, details);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class ValidationError extends HttpError {
|
|
42
|
+
constructor(message = "Validation failed", details) {
|
|
43
|
+
super(422, message, details);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class ForbiddenError extends HttpError {
|
|
48
|
+
constructor(message = "Forbidden", details) {
|
|
49
|
+
super(403, message, details);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class UnauthorizedError extends HttpError {
|
|
54
|
+
constructor(message = "Unauthorized", details) {
|
|
55
|
+
super(401, message, details);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class PayloadTooLargeError extends HttpError {
|
|
60
|
+
constructor(message = "Payload Too Large", details) {
|
|
61
|
+
super(413, message, details);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class PreconditionFailedError extends HttpError {
|
|
66
|
+
constructor(message = "Precondition Failed", details) {
|
|
67
|
+
super(412, message, details);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ../../src/core/http/etag.ts
|
|
72
|
+
function isEtagEnabled() {
|
|
73
|
+
return (process.env.FEATURE_ETAG ?? "true") !== "false";
|
|
74
|
+
}
|
|
75
|
+
function formatWeakEtag(digest) {
|
|
76
|
+
return `W/"${digest}"`;
|
|
77
|
+
}
|
|
78
|
+
function computeEtagFromJson(data) {
|
|
79
|
+
const digest = createHash("sha256").update(JSON.stringify(data)).digest("hex").slice(0, 32);
|
|
80
|
+
return formatWeakEtag(digest);
|
|
81
|
+
}
|
|
82
|
+
function etagFromResource(resource) {
|
|
83
|
+
const version = resource.updated_at ?? resource.created_at ?? "";
|
|
84
|
+
const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
|
|
85
|
+
const digest = createHash("sha256").update(`${String(resource.id ?? "0")}:${versionText}`).digest("hex").slice(0, 32);
|
|
86
|
+
return formatWeakEtag(digest);
|
|
87
|
+
}
|
|
88
|
+
function normalizeEtag(value) {
|
|
89
|
+
return value.trim();
|
|
90
|
+
}
|
|
91
|
+
function etagValuesMatch(left, right) {
|
|
92
|
+
return normalizeEtag(left) === normalizeEtag(right);
|
|
93
|
+
}
|
|
94
|
+
function parseEtagList(header) {
|
|
95
|
+
if (!header) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
function ifNoneMatchSatisfied(request, etag) {
|
|
101
|
+
const header = request.headers.get("if-none-match");
|
|
102
|
+
if (!header) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (header.trim() === "*") {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
109
|
+
}
|
|
110
|
+
function ifMatchSatisfied(request, etag) {
|
|
111
|
+
const header = request.headers.get("if-match");
|
|
112
|
+
if (!header) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (header.trim() === "*") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
119
|
+
}
|
|
120
|
+
function assertIfMatch(request, etag, options = {}) {
|
|
121
|
+
const header = request.headers.get("if-match");
|
|
122
|
+
if (!header) {
|
|
123
|
+
if (options.required) {
|
|
124
|
+
throw new PreconditionFailedError("If-Match header is required.");
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!ifMatchSatisfied(request, etag)) {
|
|
129
|
+
throw new PreconditionFailedError("Resource ETag does not match If-Match.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function applyEtagHeaders(headers, etag) {
|
|
133
|
+
const next = new Headers(headers);
|
|
134
|
+
next.set("ETag", etag);
|
|
135
|
+
next.set("Cache-Control", "private, must-revalidate");
|
|
136
|
+
next.append("Vary", "Authorization");
|
|
137
|
+
next.append("Vary", "X-Tenant-Id");
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
function notModifiedResponse(etag) {
|
|
141
|
+
return new Response(null, {
|
|
142
|
+
status: 304,
|
|
143
|
+
headers: applyEtagHeaders(new Headers, etag)
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function applyConditionalGet(request, response, etag) {
|
|
147
|
+
if (!isEtagEnabled()) {
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
151
|
+
return notModifiedResponse(etag);
|
|
152
|
+
}
|
|
153
|
+
const headers = applyEtagHeaders(new Headers(response.headers), etag);
|
|
154
|
+
return new Response(response.body, {
|
|
155
|
+
status: response.status,
|
|
156
|
+
statusText: response.statusText,
|
|
157
|
+
headers
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export {
|
|
161
|
+
notModifiedResponse,
|
|
162
|
+
isEtagEnabled,
|
|
163
|
+
ifNoneMatchSatisfied,
|
|
164
|
+
ifMatchSatisfied,
|
|
165
|
+
etagValuesMatch,
|
|
166
|
+
etagFromResource,
|
|
167
|
+
computeEtagFromJson,
|
|
168
|
+
assertIfMatch,
|
|
169
|
+
applyConditionalGet
|
|
170
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
export {
|
|
101
|
+
withFlashClear,
|
|
102
|
+
pullFlash,
|
|
103
|
+
flashResponse,
|
|
104
|
+
createFlashCookie,
|
|
105
|
+
clearFlashCookie
|
|
106
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/middleware.ts
|
|
3
|
+
function isRouteHandler(value) {
|
|
4
|
+
return typeof value === "function";
|
|
5
|
+
}
|
|
6
|
+
function isMethodRouteMap(value) {
|
|
7
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const entries = Object.entries(value);
|
|
11
|
+
return entries.length > 0 && entries.every(([, handler]) => isRouteHandler(handler));
|
|
12
|
+
}
|
|
13
|
+
function composeMiddleware(...middleware) {
|
|
14
|
+
return (handler) => {
|
|
15
|
+
return async (request) => {
|
|
16
|
+
let index = 0;
|
|
17
|
+
const dispatch = async () => {
|
|
18
|
+
if (index >= middleware.length) {
|
|
19
|
+
return await handler(request);
|
|
20
|
+
}
|
|
21
|
+
const current = middleware[index];
|
|
22
|
+
index += 1;
|
|
23
|
+
if (!current) {
|
|
24
|
+
return await handler(request);
|
|
25
|
+
}
|
|
26
|
+
return await current(request, dispatch);
|
|
27
|
+
};
|
|
28
|
+
return await dispatch();
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function requestIdMiddleware(request, next) {
|
|
33
|
+
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
34
|
+
const response = await next();
|
|
35
|
+
const headers = new Headers(response.headers);
|
|
36
|
+
headers.set("x-request-id", requestId);
|
|
37
|
+
return new Response(response.body, {
|
|
38
|
+
status: response.status,
|
|
39
|
+
statusText: response.statusText,
|
|
40
|
+
headers
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function wrapRouteHandler(handler, middleware) {
|
|
44
|
+
if (isMethodRouteMap(handler)) {
|
|
45
|
+
const wrapped = {};
|
|
46
|
+
for (const [method, routeHandler] of Object.entries(handler)) {
|
|
47
|
+
wrapped[method] = composeMiddleware(...middleware)(routeHandler);
|
|
48
|
+
}
|
|
49
|
+
return wrapped;
|
|
50
|
+
}
|
|
51
|
+
if (isRouteHandler(handler)) {
|
|
52
|
+
return composeMiddleware(...middleware)(handler);
|
|
53
|
+
}
|
|
54
|
+
return handler;
|
|
55
|
+
}
|
|
56
|
+
function applyMiddlewareToRoutes(routes, middleware) {
|
|
57
|
+
const wrapped = {};
|
|
58
|
+
for (const [path, routeHandler] of Object.entries(routes)) {
|
|
59
|
+
wrapped[path] = wrapRouteHandler(routeHandler, middleware);
|
|
60
|
+
}
|
|
61
|
+
return wrapped;
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
wrapRouteHandler,
|
|
65
|
+
requestIdMiddleware,
|
|
66
|
+
composeMiddleware,
|
|
67
|
+
applyMiddlewareToRoutes
|
|
68
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
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/parseFormBody.ts
|
|
69
|
+
async function parseFormBody(request) {
|
|
70
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
71
|
+
if (!contentType.includes("application/x-www-form-urlencoded") && !contentType.includes("multipart/form-data")) {
|
|
72
|
+
throw new BadRequestError("Expected a form submission.");
|
|
73
|
+
}
|
|
74
|
+
return formDataToRecord(await request.formData());
|
|
75
|
+
}
|
|
76
|
+
function formDataToRecord(formData) {
|
|
77
|
+
const values = {};
|
|
78
|
+
for (const [key, value] of formData.entries()) {
|
|
79
|
+
if (typeof value === "string") {
|
|
80
|
+
values[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return values;
|
|
84
|
+
}
|
|
85
|
+
export {
|
|
86
|
+
parseFormBody,
|
|
87
|
+
formDataToRecord
|
|
88
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
3
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
4
|
+
var requestMetaContext = new AsyncLocalStorage;
|
|
5
|
+
function runWithRequestMeta(meta, callback) {
|
|
6
|
+
return requestMetaContext.run(meta, callback);
|
|
7
|
+
}
|
|
8
|
+
function currentRequestMeta() {
|
|
9
|
+
return requestMetaContext.getStore() ?? {
|
|
10
|
+
ipAddress: null,
|
|
11
|
+
userAgent: null
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
runWithRequestMeta,
|
|
16
|
+
requestMetaContext,
|
|
17
|
+
currentRequestMeta
|
|
18
|
+
};
|