git-paid 2.1.6 → 2.1.8

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 CHANGED
@@ -3,10 +3,24 @@
3
3
  */
4
4
 
5
5
  import crypto from "node:crypto";
6
- import { verifyLicenseToken, checkStripeCustomerStatusCached, getFounderEmailsSet } from "./auth.js";
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";
7
16
  import { getBotInboxForDomain, normalizeInboxInput } from "./inbox.js";
17
+ import { safeFetch } from "./safe-fetch.js";
18
+
19
+ export { getDomainVerificationToken };
8
20
 
9
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 }
10
24
 
11
25
  const ALLOWED_ORIGINS = new Set([
12
26
  "https://www.postbuild.dev",
@@ -195,6 +209,194 @@ export function checkRateLimit(req, bucket = "default", limitPerMinute = 20) {
195
209
  };
196
210
  }
197
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
+
198
397
  export function resetRateLimitsForTests() {
199
398
  rateLimitBuckets.clear();
399
+ verifiedDomainCache.clear();
400
+ failedDomainCache.clear();
200
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();
@@ -265,7 +300,7 @@ async function syncFromResendReceivingApi(targetInbox = "") {
265
300
  } catch {}
266
301
  }
267
302
 
268
- await ingestInboundEmail(fullItem);
303
+ await ingestInboundEmail(fullItem, { fromWebhook: false });
269
304
  }
270
305
  } catch {}
271
306
  }
@@ -295,11 +330,11 @@ export async function listInboxMessages(inboxAddress = "", options = {}) {
295
330
  headers: {
296
331
  Authorization: `Bearer ${options.bearerToken}`
297
332
  },
298
- signal: AbortSignal.timeout(3000)
333
+ signal: AbortSignal.timeout(10000)
299
334
  }
300
335
  );
336
+ const remoteData = await remoteResp.json().catch(() => ({}));
301
337
  if (remoteResp.ok) {
302
- const remoteData = await remoteResp.json();
303
338
  if (Array.isArray(remoteData.messages)) {
304
339
  for (const rm of remoteData.messages) {
305
340
  if (rm && rm.id && rm.to === normalized) {
@@ -308,6 +343,16 @@ export async function listInboxMessages(inboxAddress = "", options = {}) {
308
343
  }
309
344
  persistMessages();
310
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
+ };
311
356
  }
312
357
  } catch {}
313
358
  }
package/lib/mcp.js CHANGED
@@ -182,6 +182,9 @@ export async function autoVerifyInboxLinks(inboxAddress = "", options = {}) {
182
182
 
183
183
  return {
184
184
  inboxAddress: res.inboxAddress,
185
+ requiresDomainVerification: Boolean(res.requiresDomainVerification),
186
+ verificationToken: res.verificationToken || "",
187
+ error: res.error || "",
185
188
  totalMessages: verificationResults.length,
186
189
  verifiedCount: verificationResults.filter((x) => x.autoVerified).length,
187
190
  extractedOtps: verificationResults
@@ -893,6 +896,16 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
893
896
  bearerToken: license.token
894
897
  });
895
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
+
896
909
  const compacted = mcpInbox.compactList({
897
910
  sessionId,
898
911
  records: result.messages || []
@@ -923,7 +936,8 @@ export async function executeMcpToolCall(name, args = {}, sessionId = "mcp-stdio
923
936
 
924
937
  const fixRes = applyLocalSeoFixes(report, {
925
938
  cwd: args.cwd || process.cwd(),
926
- overwritePublic: true
939
+ overwritePublic: true,
940
+ verificationToken: license.verificationToken || ""
927
941
  });
928
942
  return fixRes;
929
943
  }
package/lib/pseo.js CHANGED
@@ -263,7 +263,7 @@ export function renderSinglePseoPage(page, allPages = [], options = {}) {
263
263
  <meta name="twitter:title" content="${escapeHtml(fullTitle)}" />
264
264
  <meta name="twitter:description" content="${escapeHtml(metaDescription)}" />
265
265
  <link rel="icon" type="image/svg+xml" href="/brand/postbuild-icon.svg" />
266
- <script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
266
+ <script type="application/ld+json">${JSON.stringify(jsonLd).replace(/</g, "\\u003c")}</script>
267
267
  <script>
268
268
  window.si = window.si || function () { (window.siq = window.siq || []).push(arguments); };
269
269
  </script>
@@ -638,7 +638,7 @@ export function renderSinglePseoPage(page, allPages = [], options = {}) {
638
638
 
639
639
  <footer class="site-footer">
640
640
  <div>
641
- <strong>postbuild.dev</strong> (<code>git-paid</code> v2.1.5) — Post-build SEO, directory launch kit &amp; verification inbox.
641
+ <strong>postbuild.dev</strong> (<code>git-paid</code> v2.1.6) — Post-build SEO, directory launch kit &amp; verification inbox.
642
642
  </div>
643
643
  <div style="display:flex;gap:14px;flex-wrap:wrap;">
644
644
  <a href="/">Home &amp; Audit</a>
@@ -663,19 +663,213 @@ export function renderSinglePseoPage(page, allPages = [], options = {}) {
663
663
  </html>`;
664
664
  }
665
665
 
666
+ export function renderBuyerStarterPseoPage(page, allPages = [], options = {}) {
667
+ const cleanDomain = String(options.domain || "example.com")
668
+ .toLowerCase()
669
+ .trim()
670
+ .replace(/^https?:\/\//, "")
671
+ .replace(/^www\./, "")
672
+ .split("/")[0];
673
+ const cleanOrig = String(options.origin || `https://${cleanDomain}`).replace(/\/$/, "");
674
+ const prodName = String(options.cleanName || cleanDomain).trim();
675
+ const canonicalUrl = `${cleanOrig}${page.path}`;
676
+ const cat = page.category || page.volume || "Guide";
677
+ const cleanHeading = String(page.keyword || humanizeSlug(page.path, prodName).title);
678
+ const pageTitle = cleanHeading.toLowerCase().includes(prodName.toLowerCase())
679
+ ? cleanHeading
680
+ : `${cleanHeading} | ${prodName}`;
681
+
682
+ const rawTagline = String(options.tagline || "").trim();
683
+ const tagline =
684
+ rawTagline &&
685
+ rawTagline.toLowerCase() !== prodName.toLowerCase() &&
686
+ !rawTagline.toLowerCase().includes(`(${cleanDomain})`)
687
+ ? rawTagline
688
+ : "";
689
+
690
+ const shortPitch = String(options.shortPitch || options.description || "").trim();
691
+ const summaryText =
692
+ shortPitch ||
693
+ `${prodName} is available at ${cleanOrig}. Customize this starter draft with your specific product workflows, screenshots, and customer examples before publishing.`;
694
+
695
+ const cats =
696
+ Array.isArray(options.categories) && options.categories.length > 0
697
+ ? options.categories.join(", ")
698
+ : "Web Application";
699
+
700
+ const metaDesc = `${cleanHeading}. ${summaryText}`.slice(0, 158).trim();
701
+ const ogImgUrl = options.ogImage || `${cleanOrig}/og-card.svg`;
702
+ const related = (allPages || []).filter((p) => p.path !== page.path).slice(0, 5);
703
+ const headingTopic = cleanHeading
704
+ .replace(new RegExp(`^${prodName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*[—–:-]?\\s*`, "i"), "")
705
+ .trim() || cleanHeading;
706
+
707
+ const hasFaqSchema = String(page.schemaType || page.kd || "").includes("FAQPage");
708
+ const faqItems = [
709
+ {
710
+ q: `What is covered in this ${headingTopic} guide?`,
711
+ a: `${summaryText}`
712
+ },
713
+ {
714
+ q: `Where can I access ${prodName}?`,
715
+ a: `Visit ${cleanOrig} to explore ${prodName} directly and review current product documentation.`
716
+ }
717
+ ];
718
+
719
+ const graph = [
720
+ {
721
+ "@type": "SoftwareApplication",
722
+ name: prodName,
723
+ url: cleanOrig,
724
+ applicationCategory: "BusinessApplication",
725
+ operatingSystem: "Web",
726
+ description: summaryText
727
+ },
728
+ {
729
+ "@type": "BreadcrumbList",
730
+ itemListElement: [
731
+ { "@type": "ListItem", position: 1, name: prodName, item: cleanOrig },
732
+ { "@type": "ListItem", position: 2, name: cat, item: canonicalUrl }
733
+ ]
734
+ }
735
+ ];
736
+ if (hasFaqSchema) {
737
+ graph.push({
738
+ "@type": "FAQPage",
739
+ mainEntity: faqItems.map((f) => ({
740
+ "@type": "Question",
741
+ name: f.q,
742
+ acceptedAnswer: { "@type": "Answer", text: f.a }
743
+ }))
744
+ });
745
+ }
746
+ const ldJson = JSON.stringify({ "@context": "https://schema.org", "@graph": graph }).replace(/</g, "\\u003c");
747
+
748
+ let bodySectionHtml = "";
749
+ if (cat === "Comparison" || cat === "Walkthrough") {
750
+ bodySectionHtml = ` <section class="card">
751
+ <h2>Evaluation Criteria: ${escapeHtml(headingTopic)}</h2>
752
+ <p>Use the worksheet below when comparing <strong>${escapeHtml(prodName)}</strong> against existing internal workflows:</p>
753
+ <table class="cmp-table">
754
+ <thead><tr><th>Criterion</th><th>${escapeHtml(prodName)} (${escapeHtml(cleanDomain)})</th><th>Current / Baseline Process</th></tr></thead>
755
+ <tbody>
756
+ <tr><td><strong>Primary Scope</strong></td><td>${escapeHtml(tagline || prodName)}</td><td>Document current team baseline</td></tr>
757
+ <tr><td><strong>Overview</strong></td><td>${escapeHtml(summaryText)}</td><td>Record current steps and handoffs</td></tr>
758
+ <tr><td><strong>Direct Access</strong></td><td><a href="${escapeHtml(cleanOrig)}">${escapeHtml(cleanOrig)}</a></td><td>Internal docs / spreadsheets</td></tr>
759
+ </tbody>
760
+ </table>
761
+ </section>`;
762
+ } else if (cat === "Calculator") {
763
+ bodySectionHtml = ` <section class="card">
764
+ <h2>Workflow Time Worksheet — ${escapeHtml(headingTopic)}</h2>
765
+ <p>Enter your team's monthly frequency and average minutes per task to estimate total monthly time spent on this workflow:</p>
766
+ <div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:18px;margin-top:12px;">
767
+ <label style="display:block;font-weight:600;margin-bottom:6px;">Monthly runs: <span id="runsVal">10 runs/mo</span></label>
768
+ <input id="runsInput" type="range" min="1" max="60" value="10" style="width:100%;margin-bottom:12px;" oninput="var r=parseInt(this.value,10)||1;var m=parseInt(document.getElementById('minsInput').value,10)||15;document.getElementById('runsVal').textContent=r+' runs/mo';document.getElementById('hrsTotal').textContent=((r*m)/60).toFixed(1)+' hrs/mo';" />
769
+ <label style="display:block;font-weight:600;margin-bottom:6px;">Minutes per run: <span id="minsVal">30 mins/run</span></label>
770
+ <input id="minsInput" type="range" min="5" max="120" step="5" value="30" style="width:100%;" oninput="var m=parseInt(this.value,10)||15;var r=parseInt(document.getElementById('runsInput').value,10)||1;document.getElementById('minsVal').textContent=m+' mins/run';document.getElementById('hrsTotal').textContent=((r*m)/60).toFixed(1)+' hrs/mo';" />
771
+ <div style="margin-top:14px;background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px;">
772
+ <div style="font-size:12px;color:#64748b;">Estimated Monthly Workflow Time (Runs &times; Minutes)</div>
773
+ <div id="hrsTotal" style="font-size:22px;font-weight:800;color:#047857;">5.0 hrs/mo</div>
774
+ </div>
775
+ </div>
776
+ </section>`;
777
+ } else {
778
+ bodySectionHtml = ` <section class="card">
779
+ <h2>Key Notes: ${escapeHtml(headingTopic)}</h2>
780
+ <ul style="padding-left:20px;margin:12px 0;">
781
+ <li style="margin-bottom:10px;"><strong>Objective:</strong> Define how ${escapeHtml(prodName)} fits into your ${escapeHtml(headingTopic.toLowerCase())} workflow.</li>
782
+ <li style="margin-bottom:10px;"><strong>Live Environment:</strong> Visit <a href="${escapeHtml(cleanOrig)}">${escapeHtml(cleanOrig)}</a> to inspect current features and setup options.</li>
783
+ <li style="margin-bottom:10px;"><strong>Customization Note:</strong> Replace this starter section with your product's specific steps, screenshots, or code examples before publishing.</li>
784
+ </ul>
785
+ </section>`;
786
+ }
787
+
788
+ return `<!DOCTYPE html>
789
+ <!-- Starter SEO draft for ${escapeHtml(cleanDomain)} (${escapeHtml(page.path)}). Review and customize with your product details before publishing to production. -->
790
+ <html lang="en">
791
+ <head>
792
+ <meta charset="UTF-8" />
793
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
794
+ <title>${escapeHtml(pageTitle)}</title>
795
+ <meta name="description" content="${escapeHtml(metaDesc)}" />
796
+ <link rel="canonical" href="${escapeHtml(canonicalUrl)}" />
797
+ <meta property="og:type" content="article" />
798
+ <meta property="og:title" content="${escapeHtml(pageTitle)}" />
799
+ <meta property="og:description" content="${escapeHtml(metaDesc)}" />
800
+ <meta property="og:url" content="${escapeHtml(canonicalUrl)}" />
801
+ <meta property="og:image" content="${escapeHtml(ogImgUrl)}" />
802
+ <meta name="twitter:card" content="summary_large_image" />
803
+ <meta name="twitter:title" content="${escapeHtml(pageTitle)}" />
804
+ <meta name="twitter:description" content="${escapeHtml(metaDesc)}" />
805
+ <meta name="twitter:image" content="${escapeHtml(ogImgUrl)}" />
806
+ <script type="application/ld+json">${ldJson}</script>
807
+ <style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:880px;margin:0 auto;padding:36px 20px 64px;line-height:1.65;color:#0f172a;background:#f8fafc}.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:26px;margin-bottom:20px}.cta{display:inline-block;background:#047857;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none;font-weight:600}.cmp-table{width:100%;border-collapse:collapse;margin-top:14px;font-size:14px}.cmp-table th,.cmp-table td{border:1px solid #e2e8f0;padding:10px 12px;text-align:left}.cmp-table th{background:#f1f5f9;font-weight:700}.faq-item{border-top:1px solid #e2e8f0;padding-top:14px;margin-top:14px}</style>
808
+ </head>
809
+ <body>
810
+ <header style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;padding-bottom:14px;border-bottom:1px solid #e2e8f0;">
811
+ <nav aria-label="Breadcrumb" style="font-size:13px;color:#64748b;"><a href="${escapeHtml(cleanOrig)}" style="font-weight:700;color:#0f172a;text-decoration:none;">${escapeHtml(prodName)}</a> / <span>${escapeHtml(cat)}</span></nav>
812
+ <a class="cta" href="${escapeHtml(cleanOrig)}" style="font-size:13px;padding:7px 14px;">Open ${escapeHtml(prodName)} &rarr;</a>
813
+ </header>
814
+ <main>
815
+ <article class="card">
816
+ <div style="font-size:12px;font-weight:700;color:#047857;text-transform:uppercase;letter-spacing:0.04em;">${escapeHtml(cat)} &middot; ${escapeHtml(cats)}</div>
817
+ <h1 style="margin:8px 0 12px;font-size:28px;line-height:1.25;">${escapeHtml(cleanHeading)}</h1>
818
+ ${tagline ? `<p style="font-size:16px;color:#334155;margin-bottom:14px;"><strong>${escapeHtml(prodName)}</strong> &mdash; ${escapeHtml(tagline)}</p>` : ""}
819
+ <p style="color:#475569;">${escapeHtml(summaryText)}</p>
820
+ <p style="margin-top:18px;"><a class="cta" href="${escapeHtml(cleanOrig)}">Explore ${escapeHtml(prodName)} on ${escapeHtml(cleanDomain)} &rarr;</a></p>
821
+ </article>
822
+ ${bodySectionHtml}
823
+ ${
824
+ hasFaqSchema
825
+ ? ` <section class="card">
826
+ <h2>Frequently Asked Questions</h2>
827
+ ${faqItems.map((f) => ` <div class="faq-item"><h3 style="margin:0 0 6px;font-size:16px;">${escapeHtml(f.q)}</h3><p style="margin:0;color:#475569;font-size:14px;">${escapeHtml(f.a)}</p></div>`).join("\n")}
828
+ </section>`
829
+ : ""
830
+ }
831
+ <section class="card">
832
+ <h2>Related ${escapeHtml(prodName)} Guides</h2>
833
+ <ul style="padding-left:20px;margin:10px 0;">
834
+ ${related.map((r) => ` <li style="margin-bottom:6px;"><a href="${escapeHtml(r.path)}" style="color:#0284c7;text-decoration:none;font-weight:500;">${escapeHtml(r.keyword || r.path)}</a> <span style="color:#64748b;font-size:12px;">(${escapeHtml(r.category || r.volume || "Guide")})</span></li>`).join("\n")}
835
+ </ul>
836
+ </section>
837
+ </main>
838
+ </body>
839
+ </html>`;
840
+ }
841
+
666
842
  /**
667
- * Writes all 25 Programmatic SEO HTML files into targetBaseDir so cleanUrls (/use-cases/...) resolve with 200 OK.
843
+ * Writes Programmatic SEO HTML files into targetBaseDir so cleanUrls (/use-cases/...) resolve with 200 OK.
844
+ * Uses the buyer-safe starter template for all customer domains, and only uses postbuild.dev's own marketing
845
+ * template when building postbuild.dev itself.
668
846
  */
669
847
  export function writePseoHtmlFiles(pseoPages = [], targetBaseDir, options = {}) {
848
+ const cleanDomain = String(options.domain || "postbuild.dev")
849
+ .toLowerCase()
850
+ .trim()
851
+ .replace(/^https?:\/\//, "")
852
+ .replace(/^www\./, "")
853
+ .split("/")[0];
854
+ const isPostbuildSelf = cleanDomain === "postbuild.dev";
670
855
  const written = [];
671
856
  for (const page of pseoPages) {
672
857
  const cleanRel = String(page.path || "").replace(/^\/+/, "");
673
858
  if (!cleanRel) continue;
674
- const outFile = path.join(targetBaseDir, `${cleanRel}.html`);
675
- fs.mkdirSync(path.dirname(outFile), { recursive: true });
676
- const html = renderSinglePseoPage(page, pseoPages, options);
677
- fs.writeFileSync(outFile, html, "utf8");
678
- written.push(outFile);
859
+ const html = isPostbuildSelf
860
+ ? renderSinglePseoPage(page, pseoPages, options)
861
+ : renderBuyerStarterPseoPage(page, pseoPages, options);
862
+ const indexOutFile = path.join(targetBaseDir, cleanRel, "index.html");
863
+ fs.mkdirSync(path.dirname(indexOutFile), { recursive: true });
864
+ fs.writeFileSync(indexOutFile, html, "utf8");
865
+ written.push(indexOutFile);
866
+
867
+ if (isPostbuildSelf) {
868
+ const flatOutFile = path.join(targetBaseDir, `${cleanRel}.html`);
869
+ fs.mkdirSync(path.dirname(flatOutFile), { recursive: true });
870
+ fs.writeFileSync(flatOutFile, html, "utf8");
871
+ }
679
872
  }
680
873
  return written;
681
874
  }
875
+
package/lib/safe-fetch.js CHANGED
@@ -145,7 +145,7 @@ export async function assertSafeUrl(rawUrl, options = {}) {
145
145
  return parsed;
146
146
  }
147
147
 
148
- async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES) {
148
+ async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES, truncateOnMaxBytes = false) {
149
149
  if (!response.body) {
150
150
  return Buffer.alloc(0);
151
151
  }
@@ -157,11 +157,19 @@ async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES) {
157
157
  const { done, value } = await reader.read();
158
158
  if (done) break;
159
159
  const buf = Buffer.from(value);
160
- total += buf.length;
161
- if (total > maxBytes) {
162
- await reader.cancel();
160
+ if (total + buf.length > maxBytes) {
161
+ if (truncateOnMaxBytes) {
162
+ const remaining = Math.max(0, maxBytes - total);
163
+ if (remaining > 0) {
164
+ chunks.push(buf.subarray(0, remaining));
165
+ }
166
+ await reader.cancel().catch(() => {});
167
+ break;
168
+ }
169
+ await reader.cancel().catch(() => {});
163
170
  throw new Error(`Response exceeded maximum size limit of ${maxBytes} bytes`);
164
171
  }
172
+ total += buf.length;
165
173
  chunks.push(buf);
166
174
  }
167
175
 
@@ -176,6 +184,7 @@ export async function safeFetch(rawUrl, options = {}) {
176
184
  timeoutMs = DEFAULT_TIMEOUT_MS,
177
185
  maxRedirects = MAX_REDIRECTS,
178
186
  maxBytes = MAX_RESPONSE_BYTES,
187
+ truncateOnMaxBytes = false,
179
188
  allowedDomains = null,
180
189
  lookupFn
181
190
  } = options;
@@ -220,7 +229,7 @@ export async function safeFetch(rawUrl, options = {}) {
220
229
  continue;
221
230
  }
222
231
 
223
- const bodyBuffer = await readResponseWithCap(resp, maxBytes);
232
+ const bodyBuffer = await readResponseWithCap(resp, maxBytes, truncateOnMaxBytes);
224
233
  return {
225
234
  ok: resp.ok,
226
235
  status: resp.status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-paid",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "description": "git push ships your code. git paid sells it. Autonomous SEO, Paywall, MCP & Directory Launch Agent (www.postbuild.dev).",
5
5
  "type": "module",
6
6
  "main": "bin/git-paid.js",