@getstrata/core 0.5.42 → 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/admin/formatValue.js +32 -0
- package/dist/entries/admin/registry.js +32 -0
- package/dist/entries/audit/siemFormatter.js +37 -0
- package/dist/entries/auth/abilityChecker.js +1 -0
- package/dist/entries/auth/membershipMiddleware.js +298 -0
- package/dist/entries/auth/scimAuthMiddleware.js +251 -0
- package/dist/entries/auth/sessionGuard.js +72 -0
- package/dist/entries/database/migrations/types.js +1 -0
- package/dist/entries/database/migrations.js +127 -0
- package/dist/entries/database/schema.js +1054 -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 +72 -0
- package/dist/entries/http/webFormRequest.js +10 -0
- package/dist/entries/logging/requestLoggingMiddleware.js +89 -0
- package/dist/entries/mail/mailer.js +208 -0
- package/dist/entries/mail/markdownMail.js +63 -0
- package/dist/entries/mail/markdownMailable.js +78 -0
- package/dist/entries/notifications.js +152 -0
- package/dist/entries/openapi/generator.js +178 -0
- package/dist/entries/openapi/validate.js +28 -0
- package/dist/entries/queue/createAppQueue.js +58 -0
- package/dist/entries/queue/failedJobRepository.js +58 -0
- package/dist/entries/queue/publicQueue.js +58 -0
- package/dist/entries/queue/queueMetrics.js +58 -0
- package/dist/entries/runtime/asyncContextStore.js +17 -0
- package/dist/entries/security/safeFetch.js +211 -0
- package/dist/entries/security/timingSafeCompare.js +14 -0
- package/dist/entries/tenant/databaseTenantContext.js +116 -0
- package/dist/entries/tenant/tenantDatabaseScope.js +10 -0
- package/dist/entries/tracing/tracingMiddleware.js +103 -0
- package/dist/entries/view.js +72 -0
- package/package.json +202 -7
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/mail/mailer.ts
|
|
3
|
+
function resolveSmtpConfig() {
|
|
4
|
+
const host = process.env.MAIL_HOST?.trim();
|
|
5
|
+
if (!host) {
|
|
6
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_HOST to be set.');
|
|
7
|
+
}
|
|
8
|
+
const from = process.env.MAIL_FROM?.trim();
|
|
9
|
+
if (!from) {
|
|
10
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
11
|
+
}
|
|
12
|
+
const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
|
|
13
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
14
|
+
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
host,
|
|
18
|
+
port,
|
|
19
|
+
from,
|
|
20
|
+
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
21
|
+
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
22
|
+
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function encodeBase64(value) {
|
|
26
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
27
|
+
}
|
|
28
|
+
function parseSmtpResponses(buffer) {
|
|
29
|
+
const responses = [];
|
|
30
|
+
let remainder = buffer;
|
|
31
|
+
while (remainder.includes(`\r
|
|
32
|
+
`)) {
|
|
33
|
+
const index = remainder.indexOf(`\r
|
|
34
|
+
`);
|
|
35
|
+
const line = remainder.slice(0, index);
|
|
36
|
+
remainder = remainder.slice(index + 2);
|
|
37
|
+
if (line.length >= 4 && line[3] === "-") {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
responses.push(line);
|
|
41
|
+
}
|
|
42
|
+
return { responses, remainder };
|
|
43
|
+
}
|
|
44
|
+
async function waitForSmtpResponse(readResponse, expectedCodes) {
|
|
45
|
+
const response = await readResponse();
|
|
46
|
+
const code = response.slice(0, 3);
|
|
47
|
+
if (!expectedCodes.includes(code)) {
|
|
48
|
+
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
52
|
+
async function openSmtpConnection(config) {
|
|
53
|
+
let buffer = "";
|
|
54
|
+
const waiters = [];
|
|
55
|
+
const readResponse = () => new Promise((resolve, reject) => {
|
|
56
|
+
const parsed = parseSmtpResponses(buffer);
|
|
57
|
+
if (parsed.responses.length > 0) {
|
|
58
|
+
buffer = parsed.remainder;
|
|
59
|
+
resolve(parsed.responses.shift());
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
waiters.push({ resolve, reject });
|
|
63
|
+
});
|
|
64
|
+
const socket = await Bun.connect({
|
|
65
|
+
hostname: config.host,
|
|
66
|
+
port: config.port,
|
|
67
|
+
socket: {
|
|
68
|
+
open() {},
|
|
69
|
+
data(_socket, chunk) {
|
|
70
|
+
buffer += Buffer.from(chunk).toString("utf8");
|
|
71
|
+
const parsed = parseSmtpResponses(buffer);
|
|
72
|
+
buffer = parsed.remainder;
|
|
73
|
+
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
74
|
+
const response = parsed.responses.shift();
|
|
75
|
+
waiters.shift()?.resolve(response);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
error(_socket, error) {
|
|
79
|
+
const pending = waiters.splice(0);
|
|
80
|
+
for (const waiter of pending) {
|
|
81
|
+
waiter.reject(error instanceof Error ? error : new Error(String(error)));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return { socket, readResponse };
|
|
87
|
+
}
|
|
88
|
+
async function defaultSmtpTransport(config, message) {
|
|
89
|
+
const { socket, readResponse } = await openSmtpConnection(config);
|
|
90
|
+
try {
|
|
91
|
+
await waitForSmtpResponse(readResponse, ["220"]);
|
|
92
|
+
await socket.write(`EHLO workhub.local\r
|
|
93
|
+
`);
|
|
94
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
95
|
+
if (config.username && config.password) {
|
|
96
|
+
await socket.write(`AUTH LOGIN\r
|
|
97
|
+
`);
|
|
98
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
99
|
+
await socket.write(`${encodeBase64(config.username)}\r
|
|
100
|
+
`);
|
|
101
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
102
|
+
await socket.write(`${encodeBase64(config.password)}\r
|
|
103
|
+
`);
|
|
104
|
+
await waitForSmtpResponse(readResponse, ["235"]);
|
|
105
|
+
}
|
|
106
|
+
await socket.write(`MAIL FROM:<${config.from}>\r
|
|
107
|
+
`);
|
|
108
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
109
|
+
await socket.write(`RCPT TO:<${message.to}>\r
|
|
110
|
+
`);
|
|
111
|
+
await waitForSmtpResponse(readResponse, ["250", "251"]);
|
|
112
|
+
await socket.write(`DATA\r
|
|
113
|
+
`);
|
|
114
|
+
await waitForSmtpResponse(readResponse, ["354"]);
|
|
115
|
+
const payload = buildSmtpPayload(config.from, message);
|
|
116
|
+
await socket.write(payload);
|
|
117
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
118
|
+
await socket.write(`QUIT\r
|
|
119
|
+
`);
|
|
120
|
+
await waitForSmtpResponse(readResponse, ["221"]);
|
|
121
|
+
} finally {
|
|
122
|
+
socket.end();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function buildSmtpPayload(from, message) {
|
|
126
|
+
const headers = [
|
|
127
|
+
`From: ${from}`,
|
|
128
|
+
`To: ${message.to}`,
|
|
129
|
+
`Subject: ${message.subject}`,
|
|
130
|
+
"MIME-Version: 1.0"
|
|
131
|
+
];
|
|
132
|
+
if (message.html) {
|
|
133
|
+
const boundary = `strata-${Date.now().toString(36)}`;
|
|
134
|
+
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
135
|
+
const parts = [
|
|
136
|
+
`--${boundary}`,
|
|
137
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
138
|
+
"",
|
|
139
|
+
message.body,
|
|
140
|
+
`--${boundary}`,
|
|
141
|
+
"Content-Type: text/html; charset=utf-8",
|
|
142
|
+
"",
|
|
143
|
+
message.html,
|
|
144
|
+
`--${boundary}--`,
|
|
145
|
+
""
|
|
146
|
+
];
|
|
147
|
+
return [...headers, "", ...parts, ".", ""].join(`\r
|
|
148
|
+
`);
|
|
149
|
+
}
|
|
150
|
+
headers.push("Content-Type: text/plain; charset=utf-8");
|
|
151
|
+
return [...headers, "", message.body, ".", ""].join(`\r
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class LogMailDriver {
|
|
156
|
+
async send(message) {
|
|
157
|
+
console.log(JSON.stringify({
|
|
158
|
+
level: "info",
|
|
159
|
+
channel: "mail",
|
|
160
|
+
to: message.to,
|
|
161
|
+
subject: message.subject,
|
|
162
|
+
body: message.body,
|
|
163
|
+
...message.html ? { html: message.html } : {}
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
class SmtpMailDriver {
|
|
169
|
+
config;
|
|
170
|
+
transport;
|
|
171
|
+
constructor(config, transport = defaultSmtpTransport) {
|
|
172
|
+
this.config = config;
|
|
173
|
+
this.transport = transport;
|
|
174
|
+
}
|
|
175
|
+
send(message) {
|
|
176
|
+
return this.transport(this.config, message);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
class Mailer {
|
|
181
|
+
driver;
|
|
182
|
+
constructor(driver) {
|
|
183
|
+
this.driver = driver;
|
|
184
|
+
}
|
|
185
|
+
send(message) {
|
|
186
|
+
return this.driver.send(message);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function createMailDriver() {
|
|
190
|
+
const driver = process.env.MAIL_DRIVER ?? "log";
|
|
191
|
+
if (driver === "smtp") {
|
|
192
|
+
return new SmtpMailDriver(resolveSmtpConfig());
|
|
193
|
+
}
|
|
194
|
+
return new LogMailDriver;
|
|
195
|
+
}
|
|
196
|
+
var appMailer = new Mailer(createMailDriver());
|
|
197
|
+
function mailer() {
|
|
198
|
+
return appMailer;
|
|
199
|
+
}
|
|
200
|
+
export {
|
|
201
|
+
resolveSmtpConfig,
|
|
202
|
+
mailer,
|
|
203
|
+
createMailDriver,
|
|
204
|
+
buildSmtpPayload,
|
|
205
|
+
SmtpMailDriver,
|
|
206
|
+
Mailer,
|
|
207
|
+
LogMailDriver
|
|
208
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/mail/markdownMail.ts
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
|
+
}
|
|
6
|
+
function stripMarkdown(markdown) {
|
|
7
|
+
return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
|
|
8
|
+
|
|
9
|
+
`).trim();
|
|
10
|
+
}
|
|
11
|
+
function markdownToHtml(markdown) {
|
|
12
|
+
const escaped = markdown.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
|
|
14
|
+
if (block.startsWith("<")) {
|
|
15
|
+
return block;
|
|
16
|
+
}
|
|
17
|
+
return `<p>${block.replace(/\n/g, " ")}</p>`;
|
|
18
|
+
}).join(`
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
function wrapMarkdownMailLayout(bodyHtml, options = {}) {
|
|
22
|
+
const title = escapeHtml(options.title ?? "GetStrata");
|
|
23
|
+
const preview = escapeHtml(options.preview ?? "");
|
|
24
|
+
const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
|
|
25
|
+
return `<!DOCTYPE html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30
|
+
<title>${title}</title>
|
|
31
|
+
<style>
|
|
32
|
+
body { font-family: system-ui, sans-serif; line-height: 1.5; color: #111827; background: #f9fafb; margin: 0; padding: 24px; }
|
|
33
|
+
.container { max-width: 640px; margin: 0 auto; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
|
34
|
+
.header { padding: 20px 24px; border-bottom: 1px solid #e5e7eb; font-weight: 600; }
|
|
35
|
+
.content { padding: 24px; }
|
|
36
|
+
.footer { padding: 16px 24px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px; }
|
|
37
|
+
a { color: #2563eb; }
|
|
38
|
+
code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }
|
|
39
|
+
pre { background: #111827; color: #f9fafb; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
${preview ? `<span style="display:none;max-height:0;overflow:hidden;">${preview}</span>` : ""}
|
|
44
|
+
<div class="container">
|
|
45
|
+
<div class="header">${title}</div>
|
|
46
|
+
<div class="content">${bodyHtml}</div>
|
|
47
|
+
<div class="footer">${footer}</div>
|
|
48
|
+
</div>
|
|
49
|
+
</body>
|
|
50
|
+
</html>`;
|
|
51
|
+
}
|
|
52
|
+
function renderMarkdownMail(markdown, options = {}) {
|
|
53
|
+
const bodyHtml = markdownToHtml(markdown.trim());
|
|
54
|
+
const html = wrapMarkdownMailLayout(bodyHtml, options);
|
|
55
|
+
const text = stripMarkdown(markdown);
|
|
56
|
+
return { html, text };
|
|
57
|
+
}
|
|
58
|
+
export {
|
|
59
|
+
wrapMarkdownMailLayout,
|
|
60
|
+
stripMarkdown,
|
|
61
|
+
renderMarkdownMail,
|
|
62
|
+
markdownToHtml
|
|
63
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/mail/markdownMail.ts
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
|
+
}
|
|
6
|
+
function stripMarkdown(markdown) {
|
|
7
|
+
return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
|
|
8
|
+
|
|
9
|
+
`).trim();
|
|
10
|
+
}
|
|
11
|
+
function markdownToHtml(markdown) {
|
|
12
|
+
const escaped = markdown.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
|
|
14
|
+
if (block.startsWith("<")) {
|
|
15
|
+
return block;
|
|
16
|
+
}
|
|
17
|
+
return `<p>${block.replace(/\n/g, " ")}</p>`;
|
|
18
|
+
}).join(`
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
function wrapMarkdownMailLayout(bodyHtml, options = {}) {
|
|
22
|
+
const title = escapeHtml(options.title ?? "GetStrata");
|
|
23
|
+
const preview = escapeHtml(options.preview ?? "");
|
|
24
|
+
const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
|
|
25
|
+
return `<!DOCTYPE html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30
|
+
<title>${title}</title>
|
|
31
|
+
<style>
|
|
32
|
+
body { font-family: system-ui, sans-serif; line-height: 1.5; color: #111827; background: #f9fafb; margin: 0; padding: 24px; }
|
|
33
|
+
.container { max-width: 640px; margin: 0 auto; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
|
34
|
+
.header { padding: 20px 24px; border-bottom: 1px solid #e5e7eb; font-weight: 600; }
|
|
35
|
+
.content { padding: 24px; }
|
|
36
|
+
.footer { padding: 16px 24px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px; }
|
|
37
|
+
a { color: #2563eb; }
|
|
38
|
+
code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }
|
|
39
|
+
pre { background: #111827; color: #f9fafb; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
${preview ? `<span style="display:none;max-height:0;overflow:hidden;">${preview}</span>` : ""}
|
|
44
|
+
<div class="container">
|
|
45
|
+
<div class="header">${title}</div>
|
|
46
|
+
<div class="content">${bodyHtml}</div>
|
|
47
|
+
<div class="footer">${footer}</div>
|
|
48
|
+
</div>
|
|
49
|
+
</body>
|
|
50
|
+
</html>`;
|
|
51
|
+
}
|
|
52
|
+
function renderMarkdownMail(markdown, options = {}) {
|
|
53
|
+
const bodyHtml = markdownToHtml(markdown.trim());
|
|
54
|
+
const html = wrapMarkdownMailLayout(bodyHtml, options);
|
|
55
|
+
const text = stripMarkdown(markdown);
|
|
56
|
+
return { html, text };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ../../src/core/mail/markdownMailable.ts
|
|
60
|
+
function buildMarkdownMailMessage(input) {
|
|
61
|
+
const rendered = renderMarkdownMail(input.markdown, {
|
|
62
|
+
title: input.layout?.title ?? input.subject,
|
|
63
|
+
...input.layout
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
to: input.to,
|
|
67
|
+
subject: input.subject,
|
|
68
|
+
body: rendered.text,
|
|
69
|
+
html: rendered.html
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function sendMarkdownMail(mailer, input) {
|
|
73
|
+
await mailer.send(buildMarkdownMailMessage(input));
|
|
74
|
+
}
|
|
75
|
+
export {
|
|
76
|
+
sendMarkdownMail,
|
|
77
|
+
buildMarkdownMailMessage
|
|
78
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/mail/markdownMail.ts
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
5
|
+
}
|
|
6
|
+
function stripMarkdown(markdown) {
|
|
7
|
+
return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
|
|
8
|
+
|
|
9
|
+
`).trim();
|
|
10
|
+
}
|
|
11
|
+
function markdownToHtml(markdown) {
|
|
12
|
+
const escaped = markdown.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
13
|
+
return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
|
|
14
|
+
if (block.startsWith("<")) {
|
|
15
|
+
return block;
|
|
16
|
+
}
|
|
17
|
+
return `<p>${block.replace(/\n/g, " ")}</p>`;
|
|
18
|
+
}).join(`
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
function wrapMarkdownMailLayout(bodyHtml, options = {}) {
|
|
22
|
+
const title = escapeHtml(options.title ?? "GetStrata");
|
|
23
|
+
const preview = escapeHtml(options.preview ?? "");
|
|
24
|
+
const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
|
|
25
|
+
return `<!DOCTYPE html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30
|
+
<title>${title}</title>
|
|
31
|
+
<style>
|
|
32
|
+
body { font-family: system-ui, sans-serif; line-height: 1.5; color: #111827; background: #f9fafb; margin: 0; padding: 24px; }
|
|
33
|
+
.container { max-width: 640px; margin: 0 auto; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
|
|
34
|
+
.header { padding: 20px 24px; border-bottom: 1px solid #e5e7eb; font-weight: 600; }
|
|
35
|
+
.content { padding: 24px; }
|
|
36
|
+
.footer { padding: 16px 24px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 14px; }
|
|
37
|
+
a { color: #2563eb; }
|
|
38
|
+
code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }
|
|
39
|
+
pre { background: #111827; color: #f9fafb; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
${preview ? `<span style="display:none;max-height:0;overflow:hidden;">${preview}</span>` : ""}
|
|
44
|
+
<div class="container">
|
|
45
|
+
<div class="header">${title}</div>
|
|
46
|
+
<div class="content">${bodyHtml}</div>
|
|
47
|
+
<div class="footer">${footer}</div>
|
|
48
|
+
</div>
|
|
49
|
+
</body>
|
|
50
|
+
</html>`;
|
|
51
|
+
}
|
|
52
|
+
function renderMarkdownMail(markdown, options = {}) {
|
|
53
|
+
const bodyHtml = markdownToHtml(markdown.trim());
|
|
54
|
+
const html = wrapMarkdownMailLayout(bodyHtml, options);
|
|
55
|
+
const text = stripMarkdown(markdown);
|
|
56
|
+
return { html, text };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ../../src/core/mail/markdownMailable.ts
|
|
60
|
+
function buildMarkdownMailMessage(input) {
|
|
61
|
+
const rendered = renderMarkdownMail(input.markdown, {
|
|
62
|
+
title: input.layout?.title ?? input.subject,
|
|
63
|
+
...input.layout
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
to: input.to,
|
|
67
|
+
subject: input.subject,
|
|
68
|
+
body: rendered.text,
|
|
69
|
+
html: rendered.html
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
async function sendMarkdownMail(mailer, input) {
|
|
73
|
+
await mailer.send(buildMarkdownMailMessage(input));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ../../src/core/notifications/dispatcher.ts
|
|
77
|
+
class NotificationDispatcher {
|
|
78
|
+
mailer;
|
|
79
|
+
databaseStore;
|
|
80
|
+
constructor(mailer, databaseStore = null) {
|
|
81
|
+
this.mailer = mailer;
|
|
82
|
+
this.databaseStore = databaseStore;
|
|
83
|
+
}
|
|
84
|
+
async send(notifiable, notification) {
|
|
85
|
+
for (const channel of notification.via(notifiable)) {
|
|
86
|
+
if (channel === "mail") {
|
|
87
|
+
await this.sendMail(notifiable, notification);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (channel === "database") {
|
|
91
|
+
await this.sendDatabase(notifiable, notification);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async sendMail(notifiable, notification) {
|
|
96
|
+
const routed = notifiable.routeNotificationFor("mail");
|
|
97
|
+
if (routed === null) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const message = notification.toMail(notifiable);
|
|
101
|
+
if (!message) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (message.markdown) {
|
|
105
|
+
await this.mailer.send(buildMarkdownMailMessage({
|
|
106
|
+
to: String(routed),
|
|
107
|
+
subject: message.subject,
|
|
108
|
+
markdown: message.markdown
|
|
109
|
+
}));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await this.mailer.send({
|
|
113
|
+
to: String(routed),
|
|
114
|
+
subject: message.subject,
|
|
115
|
+
body: message.body ?? "",
|
|
116
|
+
...message.html ? { html: message.html } : {}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async sendDatabase(notifiable, notification) {
|
|
120
|
+
if (!this.databaseStore) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const payload = notification.toDatabase(notifiable);
|
|
124
|
+
if (!payload) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await this.databaseStore.create({
|
|
128
|
+
userId: Number(notifiable.getNotificationKey()),
|
|
129
|
+
...payload
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function createNotificationDispatcher(mailer, databaseStore) {
|
|
134
|
+
return new NotificationDispatcher(mailer, databaseStore ?? null);
|
|
135
|
+
}
|
|
136
|
+
// ../../src/core/notifications/notification.ts
|
|
137
|
+
class Notification {
|
|
138
|
+
via(_notifiable) {
|
|
139
|
+
throw new Error("Notification subclasses must implement via().");
|
|
140
|
+
}
|
|
141
|
+
toMail(_notifiable) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
toDatabase(_notifiable) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export {
|
|
149
|
+
createNotificationDispatcher,
|
|
150
|
+
NotificationDispatcher,
|
|
151
|
+
Notification
|
|
152
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/config/app.ts
|
|
3
|
+
var appConfig = {
|
|
4
|
+
name: "WorkHub",
|
|
5
|
+
env: process.env.APP_ENV ?? "local",
|
|
6
|
+
debug: (process.env.APP_DEBUG ?? "true") !== "false",
|
|
7
|
+
url: process.env.APP_URL ?? "http://localhost:3000",
|
|
8
|
+
apiPrefix: process.env.API_PREFIX ?? "/api/v1"
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// ../../src/core/openapi/generator.ts
|
|
12
|
+
var PUBLIC_ROUTE_DESCRIPTIONS = {
|
|
13
|
+
"GET /auth/me": "Current authenticated user",
|
|
14
|
+
"POST /auth/login": "Login with email and password",
|
|
15
|
+
"GET /auth/tokens": "List API tokens",
|
|
16
|
+
"POST /auth/tokens": "Create API token",
|
|
17
|
+
"DELETE /auth/tokens/:id": "Revoke API token",
|
|
18
|
+
"GET /users/me/export": "GDPR export of user data",
|
|
19
|
+
"DELETE /users/me": "GDPR account erasure (anonymize user, revoke tokens)",
|
|
20
|
+
"GET /organizations": "List organizations",
|
|
21
|
+
"POST /organizations": "Create organization",
|
|
22
|
+
"GET /organizations/:id/members": "List organization members",
|
|
23
|
+
"GET /projects": "List projects",
|
|
24
|
+
"POST /projects": "Create project",
|
|
25
|
+
"GET /tasks": "List tasks",
|
|
26
|
+
"POST /tasks": "Create task",
|
|
27
|
+
"GET /search": "Full-text search tasks and comments",
|
|
28
|
+
"GET /audit-logs": "List audit log entries",
|
|
29
|
+
"GET /webhooks": "List webhooks",
|
|
30
|
+
"POST /webhooks": "Create webhook",
|
|
31
|
+
"GET /reports/summary": "Cross-module summary report",
|
|
32
|
+
"GET /admin/stats": "Platform statistics",
|
|
33
|
+
"GET /admin/tenants": "List tenants",
|
|
34
|
+
"GET /admin/features": "Runtime feature flags",
|
|
35
|
+
"GET /billing/subscription": "Current tenant subscription",
|
|
36
|
+
"POST /billing/webhooks/stripe": "Stripe webhook receiver (stub)",
|
|
37
|
+
"GET /scim/v2/Users": "SCIM list users",
|
|
38
|
+
"POST /scim/v2/Users": "SCIM create user",
|
|
39
|
+
"GET /scim/v2/Groups": "SCIM list groups (organizations)",
|
|
40
|
+
"GET /health": "Liveness probe",
|
|
41
|
+
"GET /ready": "Readiness probe",
|
|
42
|
+
"GET /metrics": "Prometheus metrics"
|
|
43
|
+
};
|
|
44
|
+
function toOpenApiPath(path) {
|
|
45
|
+
return path.replace(/:([A-Za-z_]+)/g, "{$1}");
|
|
46
|
+
}
|
|
47
|
+
function requiresBearerAuth(path, method) {
|
|
48
|
+
if (path.startsWith("/auth/login") || path.startsWith("/auth/oauth")) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (path.startsWith("/scim/") || path.startsWith("/billing/webhooks/")) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (["/health", "/ready", "/metrics", "/"].includes(path)) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (method === "GET" && ["/organizations", "/projects", "/tasks", "/search"].some((prefix) => path.startsWith(prefix))) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return path.startsWith("/auth/") || ["POST", "PATCH", "PUT", "DELETE"].includes(method);
|
|
61
|
+
}
|
|
62
|
+
function generateOpenApiSpec(routes) {
|
|
63
|
+
const paths = {};
|
|
64
|
+
for (const route of routes) {
|
|
65
|
+
const openApiPath = toOpenApiPath(route.path);
|
|
66
|
+
const method = route.method.toLowerCase();
|
|
67
|
+
const description = PUBLIC_ROUTE_DESCRIPTIONS[`${route.method} ${route.path}`] ?? `${route.method} ${route.path}`;
|
|
68
|
+
paths[openApiPath] ??= {};
|
|
69
|
+
paths[openApiPath][method] = {
|
|
70
|
+
summary: description,
|
|
71
|
+
...requiresBearerAuth(route.path, route.method) ? { security: [{ bearerAuth: [] }] } : {},
|
|
72
|
+
responses: {
|
|
73
|
+
"200": { description: "OK" },
|
|
74
|
+
"201": { description: "Created" },
|
|
75
|
+
"204": { description: "No Content" },
|
|
76
|
+
"400": { description: "Bad Request" },
|
|
77
|
+
"401": { description: "Unauthorized" },
|
|
78
|
+
"403": { description: "Forbidden" },
|
|
79
|
+
"404": { description: "Not Found" },
|
|
80
|
+
"422": { description: "Validation Error" }
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
openapi: "3.1.0",
|
|
86
|
+
info: {
|
|
87
|
+
title: "WorkHub API",
|
|
88
|
+
version: "1.0.0"
|
|
89
|
+
},
|
|
90
|
+
servers: [
|
|
91
|
+
{ url: `${appConfig.url}${appConfig.apiPrefix}`, description: "WorkHub API" },
|
|
92
|
+
{ url: appConfig.url, description: "Root (health, metrics, SCIM)" }
|
|
93
|
+
],
|
|
94
|
+
paths,
|
|
95
|
+
components: {
|
|
96
|
+
securitySchemes: {
|
|
97
|
+
bearerAuth: {
|
|
98
|
+
type: "http",
|
|
99
|
+
scheme: "bearer"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
schemas: {
|
|
103
|
+
ErrorResponse: {
|
|
104
|
+
type: "object",
|
|
105
|
+
properties: {
|
|
106
|
+
error: { type: "string" },
|
|
107
|
+
details: { type: "object", additionalProperties: true }
|
|
108
|
+
},
|
|
109
|
+
required: ["error"]
|
|
110
|
+
},
|
|
111
|
+
UserResource: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
id: { type: "integer" },
|
|
115
|
+
name: { type: "string" },
|
|
116
|
+
email: { type: "string" },
|
|
117
|
+
role: { type: "string" }
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
OrganizationResource: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
id: { type: "integer" },
|
|
124
|
+
name: { type: "string" },
|
|
125
|
+
slug: { type: "string" }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
PaginatedMeta: {
|
|
129
|
+
type: "object",
|
|
130
|
+
properties: {
|
|
131
|
+
page: { type: "integer" },
|
|
132
|
+
per_page: { type: "integer" },
|
|
133
|
+
total: { type: "integer" },
|
|
134
|
+
last_page: { type: "integer" }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function renderOpenApiDocument(spec) {
|
|
142
|
+
return `${JSON.stringify(spec, null, 2)}
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
function toMethodName(method, path, apiPrefix) {
|
|
146
|
+
const relativePath = path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) || "/" : path;
|
|
147
|
+
const segments = relativePath.replace(/\{|\}/g, "").split("/").filter(Boolean).flatMap((segment) => segment.split("-")).map((segment) => segment.replace(/[^a-zA-Z0-9]/g, "")).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1));
|
|
148
|
+
return `${method.toLowerCase()}${segments.join("")}`;
|
|
149
|
+
}
|
|
150
|
+
function toRequestPath(path, apiPrefix) {
|
|
151
|
+
return path.startsWith(apiPrefix) ? path.slice(apiPrefix.length) || "/" : path;
|
|
152
|
+
}
|
|
153
|
+
function renderTypeScriptSdk(spec, apiPrefix = "/api/v1") {
|
|
154
|
+
const lines = [
|
|
155
|
+
"export class WorkHubClient {",
|
|
156
|
+
` constructor(private readonly baseUrl = "${spec.servers[0]?.url ?? ""}") {}`,
|
|
157
|
+
"",
|
|
158
|
+
" private async request(path: string, init: RequestInit = {}): Promise<Response> {",
|
|
159
|
+
` return await fetch(\`\${this.baseUrl}\${path}\`, init);`,
|
|
160
|
+
" }",
|
|
161
|
+
""
|
|
162
|
+
];
|
|
163
|
+
for (const [path, methods] of Object.entries(spec.paths)) {
|
|
164
|
+
const requestPath = toRequestPath(path, apiPrefix);
|
|
165
|
+
for (const method of Object.keys(methods)) {
|
|
166
|
+
const functionName = toMethodName(method, path, apiPrefix);
|
|
167
|
+
lines.push(` async ${functionName}(init: RequestInit = {}): Promise<Response> {`, ` return await this.request("${requestPath}", { ...init, method: "${method.toUpperCase()}" });`, " }", "");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
lines.push("}", "");
|
|
171
|
+
return lines.join(`
|
|
172
|
+
`);
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
renderTypeScriptSdk,
|
|
176
|
+
renderOpenApiDocument,
|
|
177
|
+
generateOpenApiSpec
|
|
178
|
+
};
|