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/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=25", {
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
- for (const item of items.slice(0, 15)) {
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
- if (!recipients.includes(normalizedTarget)) continue;
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(3000)
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.0)
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.0",
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.0",
324
+ engine: "postbuild-mcp@2.1.6",
321
325
  mcpTelemetry: {
322
- server: "postbuild-mcp@2.1.0",
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.0', tokenReductionPct: '-75.4%', supersetCacheHits: 4 };
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: "postbuild.dev", token: authToken });
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 inboxAddr = args.inbox || license.inbox || getBotInboxForDomain(license.domain || "postbuild.dev", license.email);
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: "postbuild.dev", token: authToken });
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.0"
1002
+ version: "2.1.6"
979
1003
  }
980
1004
  }
981
1005
  });