git-paid 2.1.3 → 2.1.4
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/README.md +22 -34
- package/bin/git-paid.js +14 -14
- package/lib/auth.js +866 -282
- package/lib/directories.js +123 -123
- package/lib/engine.js +228 -112
- package/lib/fixer.js +58 -0
- package/lib/inbox.js +359 -72
- package/lib/mcp.js +139 -90
- package/lib/pseo.js +290 -106
- package/lib/safe-fetch.js +239 -0
- package/lib/stateful-mcp.js +16 -4
- package/lib/submitter.js +89 -28
- package/package.json +53 -44
- package/brand/README.md +0 -54
- package/brand/postbuild-icon.png +0 -0
- package/brand/postbuild-icon.svg +0 -14
- package/brand/postbuild-logo.png +0 -0
- package/brand/postbuild-logo.svg +0 -17
- package/lib/stripe.js +0 -554
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-Safe Outbound HTTP Fetcher for Postbuild (postbuild.dev)
|
|
3
|
+
*
|
|
4
|
+
* Enforces:
|
|
5
|
+
* 1. Protocol allowlist: http: and https: only
|
|
6
|
+
* 2. Pre-request DNS resolution and private/loopback/link-local/metadata IP blocking
|
|
7
|
+
* 3. Manual redirect following (max 3 hops) with full SSRF validation on every hop
|
|
8
|
+
* 4. Strict 5-second timeout and 2 MB response body cap
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import dns from "node:dns";
|
|
12
|
+
import net from "node:net";
|
|
13
|
+
|
|
14
|
+
const MAX_REDIRECTS = 3;
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
16
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; // 2 MB
|
|
17
|
+
|
|
18
|
+
export function isPrivateOrReservedIp(ip) {
|
|
19
|
+
if (!ip || typeof ip !== "string") return true;
|
|
20
|
+
const trimmed = ip.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
21
|
+
|
|
22
|
+
// Handle IPv4-mapped IPv6 addresses in dotted-quad (::ffff:127.0.0.1) or hex-quartet (::ffff:7f00:1) form
|
|
23
|
+
const v4MappedPrefix = /^(?:::ffff:|0:0:0:0:0:ffff:)/i;
|
|
24
|
+
if (v4MappedPrefix.test(trimmed)) {
|
|
25
|
+
const suffix = trimmed.replace(v4MappedPrefix, "");
|
|
26
|
+
if (net.isIPv4(suffix)) {
|
|
27
|
+
return isPrivateOrReservedIp(suffix);
|
|
28
|
+
}
|
|
29
|
+
const hexMatch = suffix.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
|
|
30
|
+
if (hexMatch) {
|
|
31
|
+
const hi = parseInt(hexMatch[1], 16);
|
|
32
|
+
const lo = parseInt(hexMatch[2], 16);
|
|
33
|
+
const dotted = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
34
|
+
return isPrivateOrReservedIp(dotted);
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (net.isIPv4(trimmed)) {
|
|
40
|
+
const parts = trimmed.split(".").map((n) => Number(n));
|
|
41
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const [a, b, c] = parts;
|
|
45
|
+
if (a === 0) return true; // 0.0.0.0/8
|
|
46
|
+
if (a === 10) return true; // 10.0.0.0/8 (RFC1918)
|
|
47
|
+
if (a === 127) return true; // 127.0.0.0/8 (Loopback)
|
|
48
|
+
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (Link-local / AWS & GCP metadata)
|
|
49
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 (RFC1918)
|
|
50
|
+
if (a === 192 && b === 168) return true; // 192.168.0.0/16 (RFC1918)
|
|
51
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 (Carrier-grade NAT / Tailscale)
|
|
52
|
+
if (a === 192 && b === 0 && (c === 0 || c === 2)) return true; // IETF Protocol Assignments & TEST-NET-1
|
|
53
|
+
if (a === 198 && (b === 18 || b === 19)) return true; // Benchmarking (198.18.0.0/15)
|
|
54
|
+
if (a === 198 && b === 51 && c === 100) return true; // TEST-NET-2
|
|
55
|
+
if (a === 203 && b === 0 && c === 113) return true; // TEST-NET-3
|
|
56
|
+
if (a >= 224) return true; // Multicast & Reserved (224.0.0.0/4+)
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (net.isIPv6(trimmed)) {
|
|
61
|
+
if (trimmed === "::" || trimmed === "::1" || trimmed === "0:0:0:0:0:0:0:1" || trimmed === "0:0:0:0:0:0:0:0") {
|
|
62
|
+
return true; // Unspecified or loopback
|
|
63
|
+
}
|
|
64
|
+
if (trimmed.startsWith("fc") || trimmed.startsWith("fd")) return true; // Unique local fc00::/7
|
|
65
|
+
if (
|
|
66
|
+
trimmed.startsWith("fe8") ||
|
|
67
|
+
trimmed.startsWith("fe9") ||
|
|
68
|
+
trimmed.startsWith("fea") ||
|
|
69
|
+
trimmed.startsWith("feb")
|
|
70
|
+
) {
|
|
71
|
+
return true; // Link-local fe80::/10
|
|
72
|
+
}
|
|
73
|
+
if (trimmed.startsWith("ff")) return true; // Multicast ff00::/8
|
|
74
|
+
if (trimmed.startsWith("64:ff9b:")) return true; // NAT64 well-known prefix 64:ff9b::/96
|
|
75
|
+
if (trimmed.startsWith("2001:db8:")) return true; // Documentation prefix 2001:db8::/32
|
|
76
|
+
if (trimmed.startsWith("2001:0:") || trimmed.startsWith("2001::")) return true; // Teredo 2001::/32
|
|
77
|
+
if (trimmed.startsWith("2002:")) return true; // 6to4 2002::/16
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ssrfError(message) {
|
|
85
|
+
const err = new Error(message);
|
|
86
|
+
err.statusCode = 422;
|
|
87
|
+
return err;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function assertSafeUrl(rawUrl, options = {}) {
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = rawUrl instanceof URL ? rawUrl : new URL(String(rawUrl));
|
|
94
|
+
} catch {
|
|
95
|
+
throw ssrfError("Invalid URL format");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
99
|
+
throw ssrfError(`Disallowed protocol: ${parsed.protocol}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (parsed.username || parsed.password) {
|
|
103
|
+
throw ssrfError("URLs with embedded credentials are not allowed");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
107
|
+
if (
|
|
108
|
+
!hostname ||
|
|
109
|
+
hostname === "localhost" ||
|
|
110
|
+
hostname.endsWith(".localhost") ||
|
|
111
|
+
hostname.endsWith(".local") ||
|
|
112
|
+
hostname.endsWith(".internal") ||
|
|
113
|
+
hostname === "metadata.google.internal"
|
|
114
|
+
) {
|
|
115
|
+
throw ssrfError(`Blocked internal hostname: ${hostname}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (net.isIP(hostname)) {
|
|
119
|
+
if (isPrivateOrReservedIp(hostname)) {
|
|
120
|
+
throw ssrfError(`Blocked private or reserved IP address: ${hostname}`);
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lookupFn = options.lookupFn || ((host) => dns.promises.lookup(host, { all: true }));
|
|
126
|
+
let records;
|
|
127
|
+
try {
|
|
128
|
+
records = await lookupFn(hostname);
|
|
129
|
+
} catch {
|
|
130
|
+
throw ssrfError(`Unable to resolve hostname: ${hostname}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const list = Array.isArray(records) ? records : [records];
|
|
134
|
+
if (list.length === 0) {
|
|
135
|
+
throw ssrfError(`No DNS records resolved for hostname: ${hostname}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const rec of list) {
|
|
139
|
+
const addr = typeof rec === "string" ? rec : rec?.address;
|
|
140
|
+
if (!addr || isPrivateOrReservedIp(addr)) {
|
|
141
|
+
throw ssrfError(`Blocked hostname resolving to private/reserved IP (${addr || "unknown"})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return parsed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readResponseWithCap(response, maxBytes = MAX_RESPONSE_BYTES) {
|
|
149
|
+
if (!response.body) {
|
|
150
|
+
return Buffer.alloc(0);
|
|
151
|
+
}
|
|
152
|
+
const reader = response.body.getReader();
|
|
153
|
+
const chunks = [];
|
|
154
|
+
let total = 0;
|
|
155
|
+
|
|
156
|
+
while (true) {
|
|
157
|
+
const { done, value } = await reader.read();
|
|
158
|
+
if (done) break;
|
|
159
|
+
const buf = Buffer.from(value);
|
|
160
|
+
total += buf.length;
|
|
161
|
+
if (total > maxBytes) {
|
|
162
|
+
await reader.cancel();
|
|
163
|
+
throw new Error(`Response exceeded maximum size limit of ${maxBytes} bytes`);
|
|
164
|
+
}
|
|
165
|
+
chunks.push(buf);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return Buffer.concat(chunks);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function safeFetch(rawUrl, options = {}) {
|
|
172
|
+
const {
|
|
173
|
+
method = "GET",
|
|
174
|
+
headers = {},
|
|
175
|
+
body,
|
|
176
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
177
|
+
maxRedirects = MAX_REDIRECTS,
|
|
178
|
+
maxBytes = MAX_RESPONSE_BYTES,
|
|
179
|
+
allowedDomains = null,
|
|
180
|
+
lookupFn
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
let currentUrl = String(rawUrl);
|
|
184
|
+
|
|
185
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
186
|
+
const validatedUrl = await assertSafeUrl(currentUrl, { lookupFn });
|
|
187
|
+
|
|
188
|
+
if (Array.isArray(allowedDomains) && allowedDomains.length > 0) {
|
|
189
|
+
const host = validatedUrl.hostname.replace(/^www\./i, "").toLowerCase();
|
|
190
|
+
const allowed = allowedDomains.some((d) => {
|
|
191
|
+
const cleanD = String(d).replace(/^www\./i, "").toLowerCase();
|
|
192
|
+
return host === cleanD || host.endsWith(`.${cleanD}`);
|
|
193
|
+
});
|
|
194
|
+
if (!allowed) {
|
|
195
|
+
throw new Error(`Domain ${host} is not in the allowed outbound verification list`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const resp = await fetch(validatedUrl.toString(), {
|
|
204
|
+
method: hop === 0 ? method : "GET",
|
|
205
|
+
headers,
|
|
206
|
+
body: hop === 0 ? body : undefined,
|
|
207
|
+
redirect: "manual",
|
|
208
|
+
signal: controller.signal
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
if ([301, 302, 303, 307, 308].includes(resp.status)) {
|
|
212
|
+
const location = resp.headers.get("location");
|
|
213
|
+
if (!location) {
|
|
214
|
+
throw new Error(`Redirect response (${resp.status}) missing Location header`);
|
|
215
|
+
}
|
|
216
|
+
if (hop === maxRedirects) {
|
|
217
|
+
throw new Error(`Maximum redirect limit (${maxRedirects}) exceeded`);
|
|
218
|
+
}
|
|
219
|
+
currentUrl = new URL(location, validatedUrl).toString();
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const bodyBuffer = await readResponseWithCap(resp, maxBytes);
|
|
224
|
+
return {
|
|
225
|
+
ok: resp.ok,
|
|
226
|
+
status: resp.status,
|
|
227
|
+
url: validatedUrl.toString(),
|
|
228
|
+
headers: resp.headers,
|
|
229
|
+
bodyBuffer,
|
|
230
|
+
text: () => bodyBuffer.toString("utf8"),
|
|
231
|
+
json: () => JSON.parse(bodyBuffer.toString("utf8"))
|
|
232
|
+
};
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw new Error("Redirect loop terminated");
|
|
239
|
+
}
|
package/lib/stateful-mcp.js
CHANGED
|
@@ -12,9 +12,21 @@
|
|
|
12
12
|
|
|
13
13
|
import crypto from "node:crypto";
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const EPHEMERAL_MCP_SECRET = crypto.randomBytes(32).toString("hex");
|
|
16
|
+
|
|
17
|
+
export function requireMcpSecret() {
|
|
18
|
+
const value = (process.env.POSTBUILD_SECRET || process.env.POSTBUILD_AUTH_SECRET || "").trim();
|
|
19
|
+
if (value.length < 32) {
|
|
20
|
+
throw new Error("POSTBUILD_SECRET or POSTBUILD_AUTH_SECRET is missing or too short (minimum 32 characters required)");
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getMcpSecret() {
|
|
26
|
+
const configured = (process.env.POSTBUILD_SECRET || process.env.POSTBUILD_AUTH_SECRET || "").trim();
|
|
27
|
+
if (configured.length >= 32) return configured;
|
|
28
|
+
return EPHEMERAL_MCP_SECRET;
|
|
29
|
+
}
|
|
18
30
|
|
|
19
31
|
export function spotlightUntrustedText(value) {
|
|
20
32
|
if (value === null || value === undefined) return value;
|
|
@@ -369,7 +381,7 @@ export function withStatefulMcp(options = {}) {
|
|
|
369
381
|
const nonce = crypto.randomBytes(6).toString("hex");
|
|
370
382
|
const rawPayload = `${sessionId}|${principalId}|${entityId}|${liveWatermark}|${changeHash}|${nonce}`;
|
|
371
383
|
const sig = crypto
|
|
372
|
-
.createHmac("sha256",
|
|
384
|
+
.createHmac("sha256", getMcpSecret())
|
|
373
385
|
.update(rawPayload)
|
|
374
386
|
.digest("hex")
|
|
375
387
|
.slice(0, 20);
|
package/lib/submitter.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Headless Directory Form
|
|
3
|
-
* (`git
|
|
2
|
+
* Local Headless Directory Form Inspector & Launch Submitter
|
|
3
|
+
* (`npx git-paid submit <url>`)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
@@ -9,6 +9,7 @@ import { execFile } from "node:child_process";
|
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
10
|
import { scanTargetUrl, dispatchDirectories, DIRECTORIES } from "./engine.js";
|
|
11
11
|
import { autoVerifyInboxLinks } from "./mcp.js";
|
|
12
|
+
import { assertSafeUrl, safeFetch } from "./safe-fetch.js";
|
|
12
13
|
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
|
|
@@ -34,9 +35,11 @@ export function detectHeadlessBrowser() {
|
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Inspects a directory submission URL using Headless Chromium/Edge (`--headless=new --dump-dom`)
|
|
37
|
-
* or HTTP fetch to detect form inputs (`url`, `name`, `email`, `description`) and
|
|
38
|
+
* or SSRF-safe HTTP fetch to detect form inputs (`url`, `name`, `email`, `description`) and prefill.
|
|
38
39
|
*/
|
|
39
|
-
async function inspectAndSubmitDirectoryForm(dir, launchKit, browserPath, useHeadless = true) {
|
|
40
|
+
export async function inspectAndSubmitDirectoryForm(dir, launchKit, browserPath, useHeadless = true, options = {}) {
|
|
41
|
+
await assertSafeUrl(dir.url, { lookupFn: options.lookupFn });
|
|
42
|
+
|
|
40
43
|
const payload = {
|
|
41
44
|
product_name: launchKit.productName,
|
|
42
45
|
url: launchKit.utmUrl || launchKit.targetUrl,
|
|
@@ -56,7 +59,6 @@ async function inspectAndSubmitDirectoryForm(dir, launchKit, browserPath, useHea
|
|
|
56
59
|
[
|
|
57
60
|
"--headless=new",
|
|
58
61
|
"--disable-gpu",
|
|
59
|
-
"--no-sandbox",
|
|
60
62
|
"--virtual-time-budget=2500",
|
|
61
63
|
"--dump-dom",
|
|
62
64
|
dir.url
|
|
@@ -72,23 +74,20 @@ async function inspectAndSubmitDirectoryForm(dir, launchKit, browserPath, useHea
|
|
|
72
74
|
|
|
73
75
|
if (!html) {
|
|
74
76
|
try {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const resp = await fetch(dir.url, {
|
|
77
|
+
const resp = await safeFetch(dir.url, {
|
|
78
|
+
timeoutMs: 3500,
|
|
78
79
|
headers: {
|
|
79
80
|
"User-Agent":
|
|
80
81
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0.0.0 Safari/537.36"
|
|
81
82
|
},
|
|
82
|
-
|
|
83
|
+
lookupFn: options.lookupFn
|
|
83
84
|
});
|
|
84
|
-
clearTimeout(timer);
|
|
85
85
|
if (resp.ok) {
|
|
86
|
-
html =
|
|
86
|
+
html = resp.text();
|
|
87
87
|
}
|
|
88
88
|
} catch {}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// Parse detected <input>, <textarea>, and <form> elements from the live DOM
|
|
92
91
|
const inputNames = [];
|
|
93
92
|
const inputRegex = /<(?:input|textarea|select)\b[^>]*?(?:name|id|placeholder)=["']([^"']+)["'][^>]*>/gi;
|
|
94
93
|
let m;
|
|
@@ -100,7 +99,54 @@ async function inspectAndSubmitDirectoryForm(dir, launchKit, browserPath, useHea
|
|
|
100
99
|
}
|
|
101
100
|
|
|
102
101
|
const formActionMatch = html.match(/<form\b[^>]*?action=["']([^"']+)["']/i);
|
|
103
|
-
|
|
102
|
+
let detectedAction = formActionMatch ? formActionMatch[1] : dir.url;
|
|
103
|
+
if (detectedAction && detectedAction.startsWith("/")) {
|
|
104
|
+
try {
|
|
105
|
+
detectedAction = new URL(detectedAction, dir.url).toString();
|
|
106
|
+
} catch {
|
|
107
|
+
detectedAction = dir.url;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const hasCaptcha =
|
|
112
|
+
/cf-turnstile|g-recaptcha|hcaptcha/i.test(html) ||
|
|
113
|
+
inputNames.some((n) => /turnstile|captcha/i.test(n));
|
|
114
|
+
let httpSubmitStatus = null;
|
|
115
|
+
let submissionMode = hasCaptcha ? "prefilled_launch_url (captcha_gated)" : "prefilled_launch_url";
|
|
116
|
+
|
|
117
|
+
if (!hasCaptcha && formActionMatch && /^https?:\/\//i.test(detectedAction) && !process.env.VERCEL && options.allowFormPost !== false) {
|
|
118
|
+
try {
|
|
119
|
+
const formBody = new URLSearchParams();
|
|
120
|
+
for (const f of inputNames) {
|
|
121
|
+
const lower = f.toLowerCase();
|
|
122
|
+
if (lower.includes("email")) formBody.set(f, payload.email);
|
|
123
|
+
else if (lower.includes("url") || lower.includes("link") || lower.includes("website")) formBody.set(f, payload.url);
|
|
124
|
+
else if (lower.includes("tag") || lower.includes("headline") || lower.includes("short")) formBody.set(f, payload.tagline);
|
|
125
|
+
else if (lower.includes("desc") || lower.includes("about") || lower.includes("pitch")) formBody.set(f, payload.description);
|
|
126
|
+
else if (lower.includes("name") || lower.includes("title")) formBody.set(f, payload.product_name);
|
|
127
|
+
}
|
|
128
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
129
|
+
if (!formBody.has(k)) formBody.set(k, v);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const postResp = await safeFetch(detectedAction, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: {
|
|
135
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
136
|
+
"User-Agent": "Postbuild-Form-Inspector/2.1 (+https://www.postbuild.dev)"
|
|
137
|
+
},
|
|
138
|
+
body: formBody.toString(),
|
|
139
|
+
maxRedirects: 0,
|
|
140
|
+
timeoutMs: 4000,
|
|
141
|
+
lookupFn: options.lookupFn
|
|
142
|
+
});
|
|
143
|
+
httpSubmitStatus = postResp.status;
|
|
144
|
+
submissionMode = `form_post_attempted (HTTP ${postResp.status})`;
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Only count 2xx responses as submitted; 3xx redirects (e.g. to login pages) require manual confirmation
|
|
149
|
+
const postSucceeded = Boolean(httpSubmitStatus && httpSubmitStatus >= 200 && httpSubmitStatus < 300);
|
|
104
150
|
|
|
105
151
|
return {
|
|
106
152
|
id: dir.id,
|
|
@@ -112,34 +158,44 @@ async function inspectAndSubmitDirectoryForm(dir, launchKit, browserPath, useHea
|
|
|
112
158
|
prefilledUrl: dir.prefilledUrl,
|
|
113
159
|
engineUsed,
|
|
114
160
|
detectedFormAction: detectedAction,
|
|
161
|
+
hasCaptcha,
|
|
162
|
+
httpSubmitStatus,
|
|
163
|
+
submissionMode,
|
|
115
164
|
mappedFields: inputNames.length
|
|
116
165
|
? inputNames
|
|
117
166
|
: ["name", "url", "tagline", "description", "maker_email"],
|
|
118
167
|
submittedPayload: payload,
|
|
119
|
-
status:
|
|
168
|
+
status: hasCaptcha
|
|
169
|
+
? "prefilled_ready_captcha_protected"
|
|
170
|
+
: postSucceeded
|
|
171
|
+
? "submitted_awaiting_verification"
|
|
172
|
+
: "prefilled_ready_manual_confirm",
|
|
120
173
|
timestamp: new Date().toISOString()
|
|
121
174
|
};
|
|
122
175
|
}
|
|
123
176
|
|
|
124
177
|
/**
|
|
125
|
-
* Runs the
|
|
178
|
+
* Runs the Headless Directory Form Inspector + optional Inbox Verification pipeline.
|
|
126
179
|
*/
|
|
127
180
|
export async function runHeadlessDirectorySubmit({
|
|
128
|
-
targetUrl = "https://postbuild.dev",
|
|
181
|
+
targetUrl = "https://www.postbuild.dev",
|
|
129
182
|
limit = 6,
|
|
130
183
|
useHeadless = true,
|
|
131
|
-
|
|
184
|
+
autoVerify = false,
|
|
185
|
+
cwd = process.cwd(),
|
|
186
|
+
lookupFn
|
|
132
187
|
} = {}) {
|
|
133
|
-
const report = await scanTargetUrl(targetUrl);
|
|
188
|
+
const report = await scanTargetUrl(targetUrl, { lookupFn });
|
|
134
189
|
const browserPath = detectHeadlessBrowser();
|
|
135
190
|
|
|
136
|
-
// 1. Fire IndexNow
|
|
191
|
+
// 1. Fire IndexNow & WebSub dispatches across all 85 directories
|
|
137
192
|
const dispatchSummary = await dispatchDirectories({
|
|
138
193
|
targetUrl: report.targetUrl,
|
|
139
|
-
directoryIds: []
|
|
194
|
+
directoryIds: [],
|
|
195
|
+
lookupFn
|
|
140
196
|
});
|
|
141
197
|
|
|
142
|
-
// 2. Run
|
|
198
|
+
// 2. Run Headless DOM Form Inspection & Prefill on the top direct-form directories
|
|
143
199
|
const directFormDirs = (report.directories || DIRECTORIES)
|
|
144
200
|
.filter((d) => d.autoSubmit)
|
|
145
201
|
.slice(0, Math.max(1, Math.min(Number(limit) || 6, 15)));
|
|
@@ -150,13 +206,17 @@ export async function runHeadlessDirectorySubmit({
|
|
|
150
206
|
dir,
|
|
151
207
|
report.launchKit,
|
|
152
208
|
browserPath,
|
|
153
|
-
useHeadless
|
|
209
|
+
useHeadless,
|
|
210
|
+
{ lookupFn }
|
|
154
211
|
);
|
|
155
212
|
formReceipts.push(receipt);
|
|
156
213
|
}
|
|
157
214
|
|
|
158
|
-
// 3.
|
|
159
|
-
const inboxVerification = await autoVerifyInboxLinks(report.botEmail
|
|
215
|
+
// 3. Check dedicated @postbuild.dev inbox (auto-verify is opt-in only)
|
|
216
|
+
const inboxVerification = await autoVerifyInboxLinks(report.botEmail, {
|
|
217
|
+
autoVerify: Boolean(autoVerify),
|
|
218
|
+
lookupFn
|
|
219
|
+
});
|
|
160
220
|
|
|
161
221
|
const result = {
|
|
162
222
|
ok: true,
|
|
@@ -164,7 +224,7 @@ export async function runHeadlessDirectorySubmit({
|
|
|
164
224
|
targetUrl: report.targetUrl,
|
|
165
225
|
botEmail: report.botEmail,
|
|
166
226
|
headlessBrowser: browserPath ? path.basename(browserPath) : "http-fallback",
|
|
167
|
-
|
|
227
|
+
totalDirectoriesPrepared: DIRECTORIES.length,
|
|
168
228
|
headlessInspectedCount: formReceipts.length,
|
|
169
229
|
dispatchSummary: dispatchSummary.summary,
|
|
170
230
|
formReceipts,
|
|
@@ -177,10 +237,11 @@ export async function runHeadlessDirectorySubmit({
|
|
|
177
237
|
completedAt: new Date().toISOString()
|
|
178
238
|
};
|
|
179
239
|
|
|
180
|
-
//
|
|
240
|
+
// P1-7: Always save local receipt log to .postbuild/ (never public/)
|
|
181
241
|
try {
|
|
182
|
-
const
|
|
183
|
-
|
|
242
|
+
const pbDir = path.join(cwd, ".postbuild");
|
|
243
|
+
fs.mkdirSync(pbDir, { recursive: true });
|
|
244
|
+
const receiptPath = path.join(pbDir, "postbuild-submissions.json");
|
|
184
245
|
fs.writeFileSync(receiptPath, JSON.stringify(result, null, 2), "utf8");
|
|
185
246
|
result.receiptFile = receiptPath;
|
|
186
247
|
} catch {}
|
package/package.json
CHANGED
|
@@ -1,44 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "git-paid",
|
|
3
|
-
"version": "2.1.
|
|
4
|
-
"description": "git push ships your code. git paid sells it. Autonomous SEO, Paywall, MCP & Directory Launch Agent (postbuild.dev).",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "bin/git-paid.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"git-paid": "bin/git-paid.js"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
"lib/",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "git-paid",
|
|
3
|
+
"version": "2.1.4",
|
|
4
|
+
"description": "git push ships your code. git paid sells it. Autonomous SEO, Paywall, MCP & Directory Launch Agent (www.postbuild.dev).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "bin/git-paid.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"git-paid": "bin/git-paid.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/auth.js",
|
|
13
|
+
"lib/directories.js",
|
|
14
|
+
"lib/engine.js",
|
|
15
|
+
"lib/fixer.js",
|
|
16
|
+
"lib/inbox.js",
|
|
17
|
+
"lib/mcp.js",
|
|
18
|
+
"lib/pseo.js",
|
|
19
|
+
"lib/safe-fetch.js",
|
|
20
|
+
"lib/stateful-mcp.js",
|
|
21
|
+
"lib/submitter.js",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"start": "node server.js",
|
|
27
|
+
"dev": "node server.js",
|
|
28
|
+
"sola:build": "node scripts/build-sola.mjs",
|
|
29
|
+
"cli": "node ./bin/git-paid.js",
|
|
30
|
+
"test": "node --test test/security.test.js test/smoke.test.js",
|
|
31
|
+
"smoke": "node scripts/smoke.mjs"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"postbuild",
|
|
35
|
+
"git-paid",
|
|
36
|
+
"mcp",
|
|
37
|
+
"seo",
|
|
38
|
+
"programmatic-seo",
|
|
39
|
+
"stripe",
|
|
40
|
+
"monetization",
|
|
41
|
+
"directory-submission"
|
|
42
|
+
],
|
|
43
|
+
"author": "Bennett Moore (https://www.postbuild.dev)",
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/Rbm3267/postbuild.git"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://www.postbuild.dev",
|
|
49
|
+
"license": "Apache-2.0",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"stripe": "^23.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/brand/README.md
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# `postbuild` (`git paid`) — Official Brand Kit
|
|
2
|
-
|
|
3
|
-
> **`git push` ships your code. `git paid` sells it.**
|
|
4
|
-
|
|
5
|
-
## 1. Brand Assets
|
|
6
|
-
|
|
7
|
-
All assets are available in both `/brand/` (repository root) and `/public/brand/` (served live at `https://postbuild.dev/brand/...`):
|
|
8
|
-
|
|
9
|
-
| Asset | File | Dimensions | Format | Usage |
|
|
10
|
-
| :--- | :--- | :--- | :--- | :--- |
|
|
11
|
-
| **Brand Icon (PNG)** | [`postbuild-icon.png`](./postbuild-icon.png) | `512 × 512 px` | PNG | Stripe Icon, App Store / Directory listings, OAuth apps |
|
|
12
|
-
| **Horizontal Logo (PNG)** | [`postbuild-logo.png`](./postbuild-logo.png) | `1000 × 260 px` | PNG | Stripe Checkout Header Logo, Invoices, Receipts, Press Kits |
|
|
13
|
-
| **Brand Icon (SVG)** | [`postbuild-icon.svg`](./postbuild-icon.svg) | Vector (`64 × 64`) | SVG | Web UI, Favicons, Scalable vector contexts |
|
|
14
|
-
| **Horizontal Logo (SVG)** | [`postbuild-logo.svg`](./postbuild-logo.svg) | Vector (`1000 × 260`) | SVG | High-DPI headers, documentation, print |
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## 2. Visual Symbolism (`2 × 2` Grid)
|
|
19
|
-
|
|
20
|
-
The `postbuild` mark represents the two-stage developer lifecycle inside a clean `2 × 2` architectural grid:
|
|
21
|
-
1. **Top Row (`git push` — Ship the build)**: Deep Slate build module (`#0F172A`) on the left pushing outward into the Sky Blue deployment arrow (`#0284C7`) on the right.
|
|
22
|
-
2. **Bottom Row (`git paid` — Monetize & distribute)**: Muted Slate post-build artifact (`#64748B`) on the left converting into the Emerald Green revenue badge (`$`, `#10B981`) on the right.
|
|
23
|
-
|
|
24
|
-
---
|
|
25
|
-
|
|
26
|
-
## 3. Color Palette
|
|
27
|
-
|
|
28
|
-
| Role | Color Name | Hex Code | RGB | Usage |
|
|
29
|
-
| :--- | :--- | :--- | :--- | :--- |
|
|
30
|
-
| **Primary / Brand** | Deep Slate | `#0F172A` | `rgb(15, 23, 42)` | Wordmark, primary CTAs, Stripe **Brand Color** |
|
|
31
|
-
| **Accent (`git paid`)** | Emerald Green | `#10B981` | `rgb(16, 185, 129)` | `$` revenue badge, `git paid` pill, Stripe **Accent Color** |
|
|
32
|
-
| **Secondary (`git push`)** | Sky Blue | `#0284C7` | `rgb(2, 132, 199)` | Deployment arrow (`→`), technical indicators |
|
|
33
|
-
| **Structural Muted** | Slate Gray | `#64748B` | `rgb(100, 116, 139)` | Secondary build block, supporting labels |
|
|
34
|
-
| **Surface / Canvas** | Crisp Alabaster | `#F8FAFC` | `rgb(248, 250, 252)` | App background, card surfaces |
|
|
35
|
-
| **Pure White** | White | `#FFFFFF` | `rgb(255, 255, 255)` | Icon & Logo container surface |
|
|
36
|
-
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
## 4. Typography
|
|
40
|
-
|
|
41
|
-
- **Wordmark (`postbuild`)**: Always lowercase (`postbuild`), set in **Plus Jakarta Sans / Inter ExtraBold (`800`)** with `-0.045em` letter-spacing.
|
|
42
|
-
- **CLI Companion (`git paid`)**: Always lowercase (`git paid`), set in **JetBrains Mono Bold (`700`)** inside an emerald tint pill (`#ECFDF5` fill, `#A7F3D0` border, `#047857` text).
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## 5. Official Stripe & Directory Copy
|
|
47
|
-
|
|
48
|
-
- **Business Name**: `postbuild`
|
|
49
|
-
- **Website**: `https://postbuild.dev`
|
|
50
|
-
- **Statement Descriptor**: `POSTBUILD.DEV` (Shortened: `POSTBUILD`)
|
|
51
|
-
- **One-Line Tagline**:
|
|
52
|
-
> Automated post-build technical SEO, programmatic metadata, and directory launch kits for software developers.
|
|
53
|
-
- **Full Business Description**:
|
|
54
|
-
> `postbuild` (`https://postbuild.dev`) is a developer SaaS platform and CLI tool (`git paid` / `npx postbuild`) that automates post-build technical SEO audits, structured metadata (JSON-LD, OpenGraph, robots.txt, sitemaps), programmatic SEO route packs, and curated SaaS directory launch kits for software developers and product teams.
|
package/brand/postbuild-icon.png
DELETED
|
Binary file
|
package/brand/postbuild-icon.svg
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64" fill="none">
|
|
2
|
-
<rect width="64" height="64" rx="14" fill="#FFFFFF"/>
|
|
3
|
-
|
|
4
|
-
<!-- Top-left build block (git push source) -->
|
|
5
|
-
<rect x="7" y="7" width="20" height="20" rx="5.5" stroke="#0F172A" stroke-width="5" fill="#F8FAFC"/>
|
|
6
|
-
<!-- Bottom-left build block (git paid source) -->
|
|
7
|
-
<rect x="7" y="37" width="20" height="20" rx="5.5" stroke="#64748B" stroke-width="5" fill="#F8FAFC"/>
|
|
8
|
-
<!-- Top-right arrow (git push) -->
|
|
9
|
-
<path d="M36 17H56M56 17L47.5 8.5M56 17L47.5 25.5" stroke="#0284C7" stroke-width="5.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
10
|
-
<!-- Bottom-right emerald badge + crisp typographic $ (git paid) -->
|
|
11
|
-
<rect x="35" y="35" width="22" height="22" rx="6.5" fill="#10B981"/>
|
|
12
|
-
<text x="46" y="52.5" text-anchor="middle" fill="#FFFFFF" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif" font-size="18.5" font-weight="800">$</text>
|
|
13
|
-
|
|
14
|
-
</svg>
|
package/brand/postbuild-logo.png
DELETED
|
Binary file
|