git-paid 2.1.5 → 2.1.7
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/bin/git-paid.js +25 -12
- package/lib/auth.js +289 -29
- package/lib/directories.js +179 -123
- package/lib/engine.js +97 -53
- package/lib/fixer.js +129 -50
- package/lib/guard.js +402 -0
- package/lib/inbox.js +56 -10
- package/lib/mcp.js +35 -11
- package/lib/pseo.js +244 -44
- package/lib/safe-fetch.js +14 -5
- package/package.json +2 -1
package/lib/guard.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Authentication, Inbox Ownership, Admin Guard, CORS & Rate Limiting for Postbuild API Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import dns from "node:dns/promises";
|
|
7
|
+
import {
|
|
8
|
+
verifyLicenseToken,
|
|
9
|
+
checkStripeCustomerStatusCached,
|
|
10
|
+
getFounderEmailsSet,
|
|
11
|
+
getDomainVerificationToken,
|
|
12
|
+
getCandidateVerificationTokens,
|
|
13
|
+
isSameDomainVerifiedEmail,
|
|
14
|
+
isFreeEmailDomain
|
|
15
|
+
} from "./auth.js";
|
|
16
|
+
import { getBotInboxForDomain, normalizeInboxInput } from "./inbox.js";
|
|
17
|
+
import { safeFetch } from "./safe-fetch.js";
|
|
18
|
+
|
|
19
|
+
export { getDomainVerificationToken };
|
|
20
|
+
|
|
21
|
+
const rateLimitBuckets = new Map(); // `${bucket}:${ip}` -> { count: number, resetAt: number }
|
|
22
|
+
const verifiedDomainCache = new Map(); // `${domain}:${email}` -> { verifiedAt: number, method: string }
|
|
23
|
+
const failedDomainCache = new Map(); // `${domain}:${email}` -> { failedAt: number, payload: object }
|
|
24
|
+
|
|
25
|
+
const ALLOWED_ORIGINS = new Set([
|
|
26
|
+
"https://www.postbuild.dev",
|
|
27
|
+
"https://postbuild.dev"
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export function applyCorsHeaders(req, res) {
|
|
31
|
+
const origin = req.headers?.origin || "";
|
|
32
|
+
if (ALLOWED_ORIGINS.has(origin)) {
|
|
33
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
34
|
+
res.setHeader("Vary", "Origin");
|
|
35
|
+
} else {
|
|
36
|
+
res.setHeader("Access-Control-Allow-Origin", "https://www.postbuild.dev");
|
|
37
|
+
}
|
|
38
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
39
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Admin-Secret");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractBearerToken(req, body = null) {
|
|
43
|
+
const header = req.headers?.authorization || req.headers?.Authorization || "";
|
|
44
|
+
if (typeof header === "string" && header.startsWith("Bearer ")) {
|
|
45
|
+
return header.slice(7).trim();
|
|
46
|
+
}
|
|
47
|
+
if (body && typeof body === "object" && typeof body.token === "string") {
|
|
48
|
+
return body.token.trim();
|
|
49
|
+
}
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function requireLicense(req, body = null) {
|
|
54
|
+
const token = extractBearerToken(req, body);
|
|
55
|
+
if (!token) return null;
|
|
56
|
+
try {
|
|
57
|
+
const verified = verifyLicenseToken(token);
|
|
58
|
+
return verified && verified.valid && verified.email ? { ...verified, token } : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function requireLicenseAsync(req, body = null) {
|
|
65
|
+
const verified = requireLicense(req, body);
|
|
66
|
+
if (!verified) return null;
|
|
67
|
+
const remoteStatus = await checkStripeCustomerStatusCached(verified);
|
|
68
|
+
if (remoteStatus !== "active") {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return verified;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isValidProjectDomain(rawDomain = "", allowPostbuildOperator = false) {
|
|
75
|
+
const clean = String(rawDomain || "")
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/^https?:\/\//, "")
|
|
79
|
+
.replace(/^(www\.)+/, "")
|
|
80
|
+
.split("/")[0]
|
|
81
|
+
.split("?")[0]
|
|
82
|
+
.split("#")[0]
|
|
83
|
+
.split(":")[0];
|
|
84
|
+
|
|
85
|
+
if (!clean || !clean.includes(".") || clean.length < 4 || clean.length > 253) {
|
|
86
|
+
return { valid: false, domain: "" };
|
|
87
|
+
}
|
|
88
|
+
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(clean)) {
|
|
89
|
+
return { valid: false, domain: "" };
|
|
90
|
+
}
|
|
91
|
+
const labels = clean.split(".");
|
|
92
|
+
const tld = labels[labels.length - 1];
|
|
93
|
+
if (tld.length < 2 || /^\d+$/.test(tld)) {
|
|
94
|
+
return { valid: false, domain: "" };
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
clean === "example.com" ||
|
|
98
|
+
clean.endsWith(".invalid") ||
|
|
99
|
+
clean.endsWith(".localhost") ||
|
|
100
|
+
clean.endsWith(".local") ||
|
|
101
|
+
clean.endsWith(".internal")
|
|
102
|
+
) {
|
|
103
|
+
return { valid: false, domain: "" };
|
|
104
|
+
}
|
|
105
|
+
if (!allowPostbuildOperator && (clean === "postbuild.dev" || clean.endsWith(".postbuild.dev"))) {
|
|
106
|
+
return { valid: false, domain: "" };
|
|
107
|
+
}
|
|
108
|
+
return { valid: true, domain: clean };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function requireAdmin(req) {
|
|
112
|
+
const givenStr = String(req.headers?.["x-admin-secret"] || "").trim();
|
|
113
|
+
const expectedStr = String(process.env.POSTBUILD_ADMIN_SECRET || "").trim();
|
|
114
|
+
if (expectedStr.length < 32 || givenStr.length !== expectedStr.length) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const given = Buffer.from(givenStr, "utf8");
|
|
118
|
+
const expected = Buffer.from(expectedStr, "utf8");
|
|
119
|
+
return crypto.timingSafeEqual(given, expected);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Verifies that the authenticated license holder owns the requested inbox and domain.
|
|
124
|
+
* Every license is strictly bound to its own verified domain and inbox.
|
|
125
|
+
*/
|
|
126
|
+
export function isInboxOwnedByLicense(verified, requestedInbox = "", requestedDomain = "") {
|
|
127
|
+
if (!verified || !verified.valid) return false;
|
|
128
|
+
|
|
129
|
+
const licenseDomain = String(verified.domain || "")
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.trim()
|
|
132
|
+
.replace(/^https?:\/\//, "")
|
|
133
|
+
.replace(/^www\./, "")
|
|
134
|
+
.split("/")[0]
|
|
135
|
+
.split(":")[0];
|
|
136
|
+
const licenseEmail = String(verified.email || "").toLowerCase().trim();
|
|
137
|
+
if (!licenseDomain || !licenseDomain.includes(".")) return false;
|
|
138
|
+
|
|
139
|
+
const isPostbuildOperator = getFounderEmailsSet().has(licenseEmail);
|
|
140
|
+
|
|
141
|
+
// Only an explicit operator may access the postbuild.dev inbox
|
|
142
|
+
if (licenseDomain === "postbuild.dev" && !isPostbuildOperator) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (requestedDomain) {
|
|
147
|
+
const cleanReqDomain = String(requestedDomain)
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.trim()
|
|
150
|
+
.replace(/^https?:\/\//, "")
|
|
151
|
+
.replace(/^www\./, "")
|
|
152
|
+
.split("/")[0]
|
|
153
|
+
.split(":")[0];
|
|
154
|
+
if (!cleanReqDomain || cleanReqDomain !== licenseDomain) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const targetInbox = normalizeInboxInput(requestedInbox || requestedDomain || verified.inbox || licenseDomain);
|
|
160
|
+
if (!targetInbox || targetInbox === "unassigned-bot@postbuild.dev") return false;
|
|
161
|
+
if (targetInbox === "postbuild-bot@postbuild.dev" && !isPostbuildOperator) return false;
|
|
162
|
+
|
|
163
|
+
const expectedInboxWithEmail = getBotInboxForDomain(licenseDomain, licenseEmail);
|
|
164
|
+
const expectedInboxDomainOnly = getBotInboxForDomain(licenseDomain);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
targetInbox === verified.inbox ||
|
|
168
|
+
targetInbox === expectedInboxWithEmail ||
|
|
169
|
+
targetInbox === expectedInboxDomainOnly
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function getClientIp(req) {
|
|
174
|
+
const xff = req.headers?.["x-forwarded-for"];
|
|
175
|
+
if (typeof xff === "string" && xff.trim()) {
|
|
176
|
+
return xff.split(",")[0].trim();
|
|
177
|
+
}
|
|
178
|
+
return req.socket?.remoteAddress || "127.0.0.1";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function checkRateLimit(req, bucket = "default", limitPerMinute = 20) {
|
|
182
|
+
if (process.env.POSTBUILD_DISABLE_RATE_LIMIT === "1") {
|
|
183
|
+
return { allowed: true, remaining: limitPerMinute };
|
|
184
|
+
}
|
|
185
|
+
const ip = getClientIp(req);
|
|
186
|
+
const key = `${bucket}:${ip}`;
|
|
187
|
+
const now = Date.now();
|
|
188
|
+
const windowMs = 60 * 1000;
|
|
189
|
+
|
|
190
|
+
let entry = rateLimitBuckets.get(key);
|
|
191
|
+
if (!entry || now >= entry.resetAt) {
|
|
192
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
193
|
+
rateLimitBuckets.set(key, entry);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
entry.count += 1;
|
|
197
|
+
if (entry.count > limitPerMinute) {
|
|
198
|
+
return {
|
|
199
|
+
allowed: false,
|
|
200
|
+
remaining: 0,
|
|
201
|
+
retryAfterSeconds: Math.max(1, Math.ceil((entry.resetAt - now) / 1000))
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
allowed: true,
|
|
207
|
+
remaining: Math.max(0, limitPerMinute - entry.count),
|
|
208
|
+
retryAfterSeconds: 0
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function hasVerificationMetaTagInHead(rawHtml = "", expectedTokens = []) {
|
|
213
|
+
const tokenSet = new Set(Array.isArray(expectedTokens) ? expectedTokens : [expectedTokens]);
|
|
214
|
+
if (!rawHtml || tokenSet.size === 0) return false;
|
|
215
|
+
const headMatch = String(rawHtml).match(/<head\b[^>]*>([\s\S]*?)<\/head>/i);
|
|
216
|
+
const rawHeadHtml = headMatch ? headMatch[1] : String(rawHtml).split(/<body\b/i)[0] || "";
|
|
217
|
+
const sanitizedHeadHtml = rawHeadHtml
|
|
218
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
219
|
+
.replace(/<(title|script|style|noscript|template|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
|
220
|
+
const metaTags = sanitizedHeadHtml.match(/<meta\b[^>]*>/gi) || [];
|
|
221
|
+
for (const tag of metaTags) {
|
|
222
|
+
const nameMatch = tag.match(/\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/i);
|
|
223
|
+
const contentMatch = tag.match(/\bcontent\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/i);
|
|
224
|
+
const metaName = (nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3] ?? "").trim().toLowerCase();
|
|
225
|
+
const metaContent = (contentMatch?.[1] ?? contentMatch?.[2] ?? contentMatch?.[3] ?? "").trim();
|
|
226
|
+
if (metaName === "postbuild-verification" && tokenSet.has(metaContent)) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function verifyDomainOwnership(verified, requestedDomain = "", options = {}) {
|
|
234
|
+
if (!verified || !verified.valid) {
|
|
235
|
+
return { verified: false, method: "none", error: "Authentication required." };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const cleanDomain = String(requestedDomain || verified.domain || "")
|
|
239
|
+
.toLowerCase()
|
|
240
|
+
.trim()
|
|
241
|
+
.replace(/^https?:\/\//, "")
|
|
242
|
+
.replace(/^www\./, "")
|
|
243
|
+
.split("/")[0]
|
|
244
|
+
.split(":")[0];
|
|
245
|
+
const cleanEmail = String(verified.email || "").toLowerCase().trim();
|
|
246
|
+
if (!cleanDomain || !cleanDomain.includes(".")) {
|
|
247
|
+
return { verified: false, method: "none", error: "Valid project domain required." };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const expectedToken = getDomainVerificationToken(cleanDomain, cleanEmail);
|
|
251
|
+
const candidateTokens = getCandidateVerificationTokens(cleanDomain, cleanEmail);
|
|
252
|
+
const emailDomain = cleanEmail.includes("@") ? cleanEmail.split("@")[1] : "";
|
|
253
|
+
|
|
254
|
+
// 1. Operator for postbuild.dev or OTP/email-verified non-freemail address on the same domain
|
|
255
|
+
if (isSameDomainVerifiedEmail(cleanEmail, cleanDomain, Boolean(verified.emailVerified))) {
|
|
256
|
+
return {
|
|
257
|
+
verified: true,
|
|
258
|
+
method: "email_domain",
|
|
259
|
+
domain: cleanDomain,
|
|
260
|
+
verificationToken: expectedToken
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const cacheKey = `${cleanDomain}:${cleanEmail}`;
|
|
265
|
+
const cached = verifiedDomainCache.get(cacheKey);
|
|
266
|
+
if (cached && Date.now() - cached.verifiedAt < 3600 * 1000) {
|
|
267
|
+
return {
|
|
268
|
+
verified: true,
|
|
269
|
+
method: cached.method,
|
|
270
|
+
domain: cleanDomain,
|
|
271
|
+
verificationToken: expectedToken
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!options.forceFresh && !options.resolveTxtFn && !options.fetchFn) {
|
|
276
|
+
const negCached = failedDomainCache.get(cacheKey);
|
|
277
|
+
if (negCached && Date.now() - negCached.failedAt < 60 * 1000) {
|
|
278
|
+
return negCached.payload;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const resolveTxt = options.resolveTxtFn || ((host) => dns.resolveTxt(host));
|
|
283
|
+
const fetchUrl =
|
|
284
|
+
options.fetchFn ||
|
|
285
|
+
((url) => safeFetch(url, { timeoutMs: 4500, maxBytes: 256 * 1024, truncateOnMaxBytes: true }));
|
|
286
|
+
|
|
287
|
+
let dnsCheckDetail = `No matching TXT record on _postbuild.${cleanDomain}`;
|
|
288
|
+
let wellKnownCheckDetail = "Not found";
|
|
289
|
+
let htmlMetaCheckDetail = "No matching <meta> tag in homepage <head>";
|
|
290
|
+
|
|
291
|
+
// 2. Check DNS TXT on _postbuild.<domain> or <domain>
|
|
292
|
+
for (const host of [`_postbuild.${cleanDomain}`, cleanDomain]) {
|
|
293
|
+
try {
|
|
294
|
+
const records = await resolveTxt(host);
|
|
295
|
+
if (Array.isArray(records)) {
|
|
296
|
+
const flat = records.map((chunks) => (Array.isArray(chunks) ? chunks.join("") : String(chunks)).trim());
|
|
297
|
+
if (flat.some((r) => candidateTokens.some((tok) => r === tok || r.includes(tok)))) {
|
|
298
|
+
failedDomainCache.delete(cacheKey);
|
|
299
|
+
verifiedDomainCache.set(cacheKey, { verifiedAt: Date.now(), method: "dns_txt" });
|
|
300
|
+
return {
|
|
301
|
+
verified: true,
|
|
302
|
+
method: "dns_txt",
|
|
303
|
+
domain: cleanDomain,
|
|
304
|
+
verificationToken: expectedToken
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 3a. Check https://<domain>/.well-known/postbuild-verify.txt (strict line match)
|
|
312
|
+
try {
|
|
313
|
+
const resp = await fetchUrl(`https://${cleanDomain}/.well-known/postbuild-verify.txt`);
|
|
314
|
+
if (resp && resp.ok) {
|
|
315
|
+
const text = typeof resp.text === "function" ? await resp.text() : String(resp.body || "");
|
|
316
|
+
const lines = String(text || "").split(/\r?\n/).map((l) => l.trim());
|
|
317
|
+
if (candidateTokens.some((tok) => lines.includes(tok))) {
|
|
318
|
+
failedDomainCache.delete(cacheKey);
|
|
319
|
+
verifiedDomainCache.set(cacheKey, { verifiedAt: Date.now(), method: "well_known_file" });
|
|
320
|
+
return {
|
|
321
|
+
verified: true,
|
|
322
|
+
method: "well_known_file",
|
|
323
|
+
domain: cleanDomain,
|
|
324
|
+
verificationToken: expectedToken
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
wellKnownCheckDetail = "File returned 200 OK without matching token";
|
|
328
|
+
} else if (resp && resp.status) {
|
|
329
|
+
wellKnownCheckDetail = `HTTP ${resp.status}`;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
wellKnownCheckDetail = "Unreachable or timed out";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 3b. Check https://<domain>/ for <meta name="postbuild-verification" content="..."> inside <head>
|
|
336
|
+
try {
|
|
337
|
+
const resp = await fetchUrl(`https://${cleanDomain}/`);
|
|
338
|
+
if (resp && resp.ok) {
|
|
339
|
+
const text = typeof resp.text === "function" ? await resp.text() : String(resp.body || "");
|
|
340
|
+
if (hasVerificationMetaTagInHead(text, candidateTokens)) {
|
|
341
|
+
failedDomainCache.delete(cacheKey);
|
|
342
|
+
verifiedDomainCache.set(cacheKey, { verifiedAt: Date.now(), method: "html_meta" });
|
|
343
|
+
return {
|
|
344
|
+
verified: true,
|
|
345
|
+
method: "html_meta",
|
|
346
|
+
domain: cleanDomain,
|
|
347
|
+
verificationToken: expectedToken
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
htmlMetaCheckDetail = "Homepage 200 OK — <meta name=\"postbuild-verification\"> not found in <head>";
|
|
351
|
+
} else if (resp && resp.status) {
|
|
352
|
+
htmlMetaCheckDetail = `HTTP ${resp.status}`;
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
htmlMetaCheckDetail = "Homepage unreachable or timed out";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const isFree = isFreeEmailDomain(emailDomain);
|
|
359
|
+
const sameDomainUnconfirmed = Boolean(
|
|
360
|
+
emailDomain && !isFree && (cleanDomain === emailDomain || cleanDomain.endsWith(`.${emailDomain}`)) && !verified.emailVerified
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const emailDomainStatus = !emailDomain
|
|
364
|
+
? "No account email"
|
|
365
|
+
: isFree
|
|
366
|
+
? `Personal email (${emailDomain}) requires domain proof`
|
|
367
|
+
: sameDomainUnconfirmed
|
|
368
|
+
? "Confirm your email via 6-digit code to unlock automatically"
|
|
369
|
+
: `Account email (@${emailDomain}) differs from ${cleanDomain}`;
|
|
370
|
+
|
|
371
|
+
const failurePayload = {
|
|
372
|
+
verified: false,
|
|
373
|
+
method: "none",
|
|
374
|
+
domain: cleanDomain,
|
|
375
|
+
verificationToken: expectedToken,
|
|
376
|
+
sameDomainUnconfirmed,
|
|
377
|
+
checkedAt: new Date().toISOString(),
|
|
378
|
+
checks: {
|
|
379
|
+
emailDomain: emailDomainStatus,
|
|
380
|
+
dnsTxt: dnsCheckDetail,
|
|
381
|
+
wellKnownFile: wellKnownCheckDetail,
|
|
382
|
+
htmlMeta: htmlMetaCheckDetail
|
|
383
|
+
},
|
|
384
|
+
dnsHost: `_postbuild.${cleanDomain}`,
|
|
385
|
+
wellKnownPath: "/.well-known/postbuild-verify.txt",
|
|
386
|
+
metaTag: `<meta name="postbuild-verification" content="${expectedToken}" />`,
|
|
387
|
+
error: `Domain ownership proof required for ${cleanDomain} before reading ${getBotInboxForDomain(cleanDomain, cleanEmail)}. Deploy /.well-known/postbuild-verify.txt (included in your Launch Bundle .zip), add <meta name="postbuild-verification" content="${expectedToken}" /> to your homepage <head>, add DNS TXT record _postbuild.${cleanDomain} = ${expectedToken}, or sign in via 6-digit code with an @${cleanDomain} email.`
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (!options.resolveTxtFn && !options.fetchFn) {
|
|
391
|
+
failedDomainCache.set(cacheKey, { failedAt: Date.now(), payload: failurePayload });
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return failurePayload;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function resetRateLimitsForTests() {
|
|
398
|
+
rateLimitBuckets.clear();
|
|
399
|
+
verifiedDomainCache.clear();
|
|
400
|
+
failedDomainCache.clear();
|
|
401
|
+
}
|
|
402
|
+
|
package/lib/inbox.js
CHANGED
|
@@ -155,7 +155,8 @@ export function sanitizeSubjectForDisplay(subject = "", otpCode = "") {
|
|
|
155
155
|
* Ingests a verified inbound email webhook payload.
|
|
156
156
|
* Rejects mail addressed to unknown or non-@postbuild.dev recipients.
|
|
157
157
|
*/
|
|
158
|
-
export async function ingestInboundEmail(rawPayload = {}) {
|
|
158
|
+
export async function ingestInboundEmail(rawPayload = {}, options = {}) {
|
|
159
|
+
const { fromWebhook = true } = options || {};
|
|
159
160
|
const payload = rawPayload?.data && typeof rawPayload.data === "object" ? rawPayload.data : rawPayload;
|
|
160
161
|
|
|
161
162
|
const toRaw = payload.to || payload.recipient || payload.envelope?.to || "";
|
|
@@ -194,6 +195,40 @@ export async function ingestInboundEmail(rawPayload = {}) {
|
|
|
194
195
|
const fromRaw = payload.from || payload.sender || payload.envelope?.from || "no-reply@directory-verifier.com";
|
|
195
196
|
const fromAddress = String(fromRaw).trim();
|
|
196
197
|
|
|
198
|
+
// Forward inbound customer support replies (support@postbuild.dev / login@postbuild.dev) to founder email only on webhook path
|
|
199
|
+
if (toAddress === "support@postbuild.dev" || toAddress === "login@postbuild.dev") {
|
|
200
|
+
if (fromWebhook && resendApiKey) {
|
|
201
|
+
const forwardTarget = (process.env.ADMIN_FORWARD_EMAIL || process.env.ADMIN_EMAIL || "").trim();
|
|
202
|
+
if (forwardTarget && forwardTarget.includes("@")) {
|
|
203
|
+
try {
|
|
204
|
+
await fetch("https://api.resend.com/emails", {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
Authorization: `Bearer ${resendApiKey}`,
|
|
208
|
+
"Content-Type": "application/json"
|
|
209
|
+
},
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
from: "postbuild Support <login@postbuild.dev>",
|
|
212
|
+
reply_to: fromAddress,
|
|
213
|
+
to: [forwardTarget],
|
|
214
|
+
subject: `[Support ${toAddress}] ${subject}`,
|
|
215
|
+
text: `From: ${fromAddress}\nTo: ${toAddress}\nSubject: ${subject}\n\n${textBody || htmlBody}`
|
|
216
|
+
}),
|
|
217
|
+
signal: AbortSignal.timeout(4500)
|
|
218
|
+
});
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
id: resendEmailId || `support_${Date.now()}`,
|
|
224
|
+
to: toAddress,
|
|
225
|
+
from: fromAddress,
|
|
226
|
+
subject,
|
|
227
|
+
status: "forwarded",
|
|
228
|
+
receivedAt: new Date().toISOString()
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
const signals = extractVerificationSignals(subject, textBody, htmlBody);
|
|
198
233
|
const displaySubject = sanitizeSubjectForDisplay(subject, signals.otpCode);
|
|
199
234
|
const receivedDate = payload.created_at ? new Date(payload.created_at) : new Date();
|
|
@@ -232,7 +267,7 @@ async function syncFromResendReceivingApi(targetInbox = "") {
|
|
|
232
267
|
const normalizedTarget = targetInbox.trim().toLowerCase();
|
|
233
268
|
|
|
234
269
|
try {
|
|
235
|
-
const resp = await fetch("https://api.resend.com/emails/receiving?limit=
|
|
270
|
+
const resp = await fetch("https://api.resend.com/emails/receiving?limit=100", {
|
|
236
271
|
headers: { Authorization: `Bearer ${resendApiKey}` },
|
|
237
272
|
signal: AbortSignal.timeout(3500)
|
|
238
273
|
});
|
|
@@ -241,15 +276,16 @@ async function syncFromResendReceivingApi(targetInbox = "") {
|
|
|
241
276
|
const data = await resp.json();
|
|
242
277
|
const items = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [];
|
|
243
278
|
|
|
244
|
-
|
|
245
|
-
const id = item.id || item.email_id;
|
|
246
|
-
if (!id || memoryInboxMessages.has(id)) continue;
|
|
247
|
-
|
|
279
|
+
const matchingItems = items.filter((item) => {
|
|
248
280
|
const recipients = (Array.isArray(item.to) ? item.to : [item.to])
|
|
249
281
|
.filter(Boolean)
|
|
250
282
|
.map((r) => String(r).replace(/^.*<([^>]+)>.*$/, "$1").trim().toLowerCase());
|
|
283
|
+
return recipients.includes(normalizedTarget);
|
|
284
|
+
});
|
|
251
285
|
|
|
252
|
-
|
|
286
|
+
for (const item of matchingItems.slice(0, 15)) {
|
|
287
|
+
const id = item.id || item.email_id;
|
|
288
|
+
if (!id || memoryInboxMessages.has(id)) continue;
|
|
253
289
|
|
|
254
290
|
let fullItem = item;
|
|
255
291
|
if (!item.text && !item.html) {
|
|
@@ -264,7 +300,7 @@ async function syncFromResendReceivingApi(targetInbox = "") {
|
|
|
264
300
|
} catch {}
|
|
265
301
|
}
|
|
266
302
|
|
|
267
|
-
await ingestInboundEmail(fullItem);
|
|
303
|
+
await ingestInboundEmail(fullItem, { fromWebhook: false });
|
|
268
304
|
}
|
|
269
305
|
} catch {}
|
|
270
306
|
}
|
|
@@ -294,11 +330,11 @@ export async function listInboxMessages(inboxAddress = "", options = {}) {
|
|
|
294
330
|
headers: {
|
|
295
331
|
Authorization: `Bearer ${options.bearerToken}`
|
|
296
332
|
},
|
|
297
|
-
signal: AbortSignal.timeout(
|
|
333
|
+
signal: AbortSignal.timeout(10000)
|
|
298
334
|
}
|
|
299
335
|
);
|
|
336
|
+
const remoteData = await remoteResp.json().catch(() => ({}));
|
|
300
337
|
if (remoteResp.ok) {
|
|
301
|
-
const remoteData = await remoteResp.json();
|
|
302
338
|
if (Array.isArray(remoteData.messages)) {
|
|
303
339
|
for (const rm of remoteData.messages) {
|
|
304
340
|
if (rm && rm.id && rm.to === normalized) {
|
|
@@ -307,6 +343,16 @@ export async function listInboxMessages(inboxAddress = "", options = {}) {
|
|
|
307
343
|
}
|
|
308
344
|
persistMessages();
|
|
309
345
|
}
|
|
346
|
+
} else if (remoteResp.status === 403 && remoteData && remoteData.requiresDomainVerification) {
|
|
347
|
+
return {
|
|
348
|
+
inboxAddress: normalized,
|
|
349
|
+
retentionDays: 30,
|
|
350
|
+
count: 0,
|
|
351
|
+
messages: [],
|
|
352
|
+
requiresDomainVerification: true,
|
|
353
|
+
verificationToken: remoteData.verificationToken || "",
|
|
354
|
+
error: remoteData.error || "Domain ownership verification required before reading this inbox."
|
|
355
|
+
};
|
|
310
356
|
}
|
|
311
357
|
} catch {}
|
|
312
358
|
}
|
package/lib/mcp.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Postbuild Stateful MCP Server & Obsidian VaultOS Sync Engine
|
|
3
|
-
* Powered by Postbuild MCP Middleware (postbuild-mcp v2.1.
|
|
3
|
+
* Powered by Postbuild MCP Middleware (postbuild-mcp v2.1.6)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
@@ -8,6 +8,7 @@ import path from "node:path";
|
|
|
8
8
|
import readline from "node:readline";
|
|
9
9
|
import { scanTargetUrl, searchBuyerIntentRadar, dispatchDirectories, DIRECTORIES } from "./engine.js";
|
|
10
10
|
import { verifyCliLicense } from "./auth.js";
|
|
11
|
+
import { isInboxOwnedByLicense } from "./guard.js";
|
|
11
12
|
import { applyLocalSeoFixes } from "./fixer.js";
|
|
12
13
|
import { listInboxMessages, sendTestVerificationEmail, getBotInboxForDomain, markMessageVerified } from "./inbox.js";
|
|
13
14
|
import { withStatefulMcp, createGenericEntityAdapter, spotlightUntrustedText } from "./stateful-mcp.js";
|
|
@@ -181,6 +182,9 @@ export async function autoVerifyInboxLinks(inboxAddress = "", options = {}) {
|
|
|
181
182
|
|
|
182
183
|
return {
|
|
183
184
|
inboxAddress: res.inboxAddress,
|
|
185
|
+
requiresDomainVerification: Boolean(res.requiresDomainVerification),
|
|
186
|
+
verificationToken: res.verificationToken || "",
|
|
187
|
+
error: res.error || "",
|
|
184
188
|
totalMessages: verificationResults.length,
|
|
185
189
|
verifiedCount: verificationResults.filter((x) => x.autoVerified).length,
|
|
186
190
|
extractedOtps: verificationResults
|
|
@@ -238,7 +242,7 @@ export async function runStatefulMcpSimulation({
|
|
|
238
242
|
});
|
|
239
243
|
|
|
240
244
|
return {
|
|
241
|
-
server: "postbuild-mcp@2.1.
|
|
245
|
+
server: "postbuild-mcp@2.1.6",
|
|
242
246
|
domain: state.domain,
|
|
243
247
|
readinessScore: report.readinessScore,
|
|
244
248
|
botEmail: report.botEmail,
|
|
@@ -317,9 +321,9 @@ export async function syncPostbuildToVault({
|
|
|
317
321
|
|
|
318
322
|
const telemetryPayload = {
|
|
319
323
|
generatedAt: new Date().toISOString(),
|
|
320
|
-
engine: "postbuild-mcp@2.1.
|
|
324
|
+
engine: "postbuild-mcp@2.1.6",
|
|
321
325
|
mcpTelemetry: {
|
|
322
|
-
server: "postbuild-mcp@2.1.
|
|
326
|
+
server: "postbuild-mcp@2.1.6",
|
|
323
327
|
tokenReductionPct: projectsData[0]?.mcpReductionPct || "-75.4%",
|
|
324
328
|
supersetCacheHits: mcpStats.supersetCacheHits,
|
|
325
329
|
deltasEmitted: mcpStats.deltasEmitted,
|
|
@@ -424,7 +428,7 @@ try {
|
|
|
424
428
|
|
|
425
429
|
const projects = pb && pb.projects ? pb.projects : [];
|
|
426
430
|
const radar = pb && pb.radarThreads ? pb.radarThreads : [];
|
|
427
|
-
const mcp = pb && pb.mcpTelemetry ? pb.mcpTelemetry : { server: 'postbuild-mcp@2.1.
|
|
431
|
+
const mcp = pb && pb.mcpTelemetry ? pb.mcpTelemetry : { server: 'postbuild-mcp@2.1.6', tokenReductionPct: '-75.4%', supersetCacheHits: 4 };
|
|
428
432
|
|
|
429
433
|
root.innerHTML = \`
|
|
430
434
|
<div class="meta-bar">
|
|
@@ -865,7 +869,7 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
865
869
|
}
|
|
866
870
|
|
|
867
871
|
if (name === "postbuild_inbox") {
|
|
868
|
-
const license = await verifyCliLicense({ domain: "
|
|
872
|
+
const license = await verifyCliLicense({ domain: "", token: authToken });
|
|
869
873
|
if (!license.valid) {
|
|
870
874
|
return {
|
|
871
875
|
isError: true,
|
|
@@ -876,13 +880,32 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
876
880
|
};
|
|
877
881
|
}
|
|
878
882
|
|
|
879
|
-
const
|
|
883
|
+
const defaultInbox = license.inbox || getBotInboxForDomain(license.domain || "postbuild.dev", license.email);
|
|
884
|
+
const inboxAddr = args.inbox || defaultInbox;
|
|
885
|
+
if (args.inbox && !isInboxOwnedByLicense({ email: license.email, domain: license.domain, inbox: defaultInbox }, inboxAddr, "")) {
|
|
886
|
+
return {
|
|
887
|
+
isError: true,
|
|
888
|
+
status: "FORBIDDEN_INBOX",
|
|
889
|
+
message: `Your license covers ${defaultInbox}. You cannot read ${inboxAddr}.`
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
880
893
|
const autoVerify = Boolean(args.auto_verify);
|
|
881
894
|
const result = await autoVerifyInboxLinks(inboxAddr, {
|
|
882
895
|
autoVerify,
|
|
883
896
|
bearerToken: license.token
|
|
884
897
|
});
|
|
885
898
|
|
|
899
|
+
if (result.requiresDomainVerification) {
|
|
900
|
+
return {
|
|
901
|
+
isError: true,
|
|
902
|
+
status: "DOMAIN_VERIFICATION_REQUIRED",
|
|
903
|
+
inboxAddress: result.inboxAddress,
|
|
904
|
+
verificationToken: result.verificationToken,
|
|
905
|
+
message: result.error || "Verify ownership of your domain on https://www.postbuild.dev/?tab=account before reading this inbox."
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
886
909
|
const compacted = mcpInbox.compactList({
|
|
887
910
|
sessionId,
|
|
888
911
|
records: result.messages || []
|
|
@@ -913,13 +936,14 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
913
936
|
|
|
914
937
|
const fixRes = applyLocalSeoFixes(report, {
|
|
915
938
|
cwd: args.cwd || process.cwd(),
|
|
916
|
-
overwritePublic: true
|
|
939
|
+
overwritePublic: true,
|
|
940
|
+
verificationToken: license.verificationToken || ""
|
|
917
941
|
});
|
|
918
942
|
return fixRes;
|
|
919
943
|
}
|
|
920
944
|
|
|
921
945
|
if (name === "postbuild_vault_sync") {
|
|
922
|
-
const license = await verifyCliLicense({ domain: "
|
|
946
|
+
const license = await verifyCliLicense({ domain: "", token: authToken });
|
|
923
947
|
if (!license.valid) {
|
|
924
948
|
return {
|
|
925
949
|
isError: true,
|
|
@@ -932,7 +956,7 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
|
|
|
932
956
|
|
|
933
957
|
return await syncPostbuildToVault({
|
|
934
958
|
vaultPath: args.vault_path || process.cwd(),
|
|
935
|
-
urls: Array.isArray(args.urls) && args.urls.length ? args.urls : ["https://www.postbuild.dev"],
|
|
959
|
+
urls: Array.isArray(args.urls) && args.urls.length ? args.urls : [license.domain ? `https://${license.domain}` : "https://www.postbuild.dev"],
|
|
936
960
|
sessionId,
|
|
937
961
|
bearerToken: license.token
|
|
938
962
|
});
|
|
@@ -975,7 +999,7 @@ export function startPostbuildMcpServer() {
|
|
|
975
999
|
},
|
|
976
1000
|
serverInfo: {
|
|
977
1001
|
name: "postbuild-mcp",
|
|
978
|
-
version: "2.1.
|
|
1002
|
+
version: "2.1.6"
|
|
979
1003
|
}
|
|
980
1004
|
}
|
|
981
1005
|
});
|