git-paid 2.1.3 → 2.1.5
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
package/lib/auth.js
CHANGED
|
@@ -1,282 +1,866 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import os from "node:os";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
token
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
1
|
+
// Cryptographic License Verification, Entitlement Ledger & Hardened Email OTP for postbuild (www.postbuild.dev)
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import { getBotInboxForDomain } from "./inbox.js";
|
|
8
|
+
|
|
9
|
+
const otpChallengeState = new Map(); // cid -> { attempts: number, used: boolean, expiresAt: number }
|
|
10
|
+
const memoryEntitlements = new Map(); // key (jti or email) -> { jti, email, customerId, plan, domain, inbox, status, expiresAt }
|
|
11
|
+
const processedWebhookEvents = new Set(); // stripe_event_id idempotency set
|
|
12
|
+
|
|
13
|
+
export function requireAuthSecret() {
|
|
14
|
+
const explicit = (process.env.POSTBUILD_AUTH_SECRET || process.env.POSTBUILD_SECRET || "").trim();
|
|
15
|
+
if (explicit.length >= 32) {
|
|
16
|
+
return explicit;
|
|
17
|
+
}
|
|
18
|
+
// Safe domain-separated derivation on Vercel if POSTBUILD_AUTH_SECRET has not been added to project env yet
|
|
19
|
+
const resendSeed = (process.env.RESEND_API_KEY || "").trim();
|
|
20
|
+
if (process.env.VERCEL && resendSeed.length >= 24 && !process.env.POSTBUILD_STRICT_SECRET_TEST) {
|
|
21
|
+
return crypto.createHmac("sha256", resendSeed).update("postbuild:auth:v2:hmac-signing-key").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
throw new Error("POSTBUILD_SECRET or POSTBUILD_AUTH_SECRET is missing or too short (minimum 32 characters required)");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function timingSafeStringEqual(a, b) {
|
|
27
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
28
|
+
const bufA = Buffer.from(a, "utf8");
|
|
29
|
+
const bufB = Buffer.from(b, "utf8");
|
|
30
|
+
if (bufA.length !== bufB.length || bufA.length === 0) return false;
|
|
31
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── ENTITLEMENT & WEBHOOK IDEMPOTENCY LEDGER ────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export function hasProcessedWebhookEvent(eventId) {
|
|
37
|
+
if (!eventId) return false;
|
|
38
|
+
return processedWebhookEvents.has(eventId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function recordWebhookEventProcessed(eventId) {
|
|
42
|
+
if (!eventId) return false;
|
|
43
|
+
processedWebhookEvents.add(eventId);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function markWebhookEventProcessed(eventId) {
|
|
48
|
+
if (!eventId) return false;
|
|
49
|
+
if (processedWebhookEvents.has(eventId)) {
|
|
50
|
+
return false; // Already processed
|
|
51
|
+
}
|
|
52
|
+
processedWebhookEvents.add(eventId);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function upsertEntitlement({
|
|
57
|
+
jti,
|
|
58
|
+
email,
|
|
59
|
+
customerId = null,
|
|
60
|
+
plan = "launch_pass",
|
|
61
|
+
domain = "postbuild.dev",
|
|
62
|
+
status = "active",
|
|
63
|
+
expiresAt = null
|
|
64
|
+
}) {
|
|
65
|
+
const cleanEmail = String(email || "").toLowerCase().trim();
|
|
66
|
+
const cleanDomain = String(domain || "postbuild.dev")
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/^https?:\/\//, "")
|
|
70
|
+
.replace(/^www\./, "")
|
|
71
|
+
.split("/")[0] || "postbuild.dev";
|
|
72
|
+
const resolvedJti = jti || crypto.randomUUID();
|
|
73
|
+
const inbox = getBotInboxForDomain(cleanDomain, cleanEmail);
|
|
74
|
+
const record = {
|
|
75
|
+
jti: resolvedJti,
|
|
76
|
+
email: cleanEmail,
|
|
77
|
+
customerId: customerId || null,
|
|
78
|
+
plan,
|
|
79
|
+
domain: cleanDomain,
|
|
80
|
+
inbox,
|
|
81
|
+
status,
|
|
82
|
+
expiresAt: expiresAt || Math.floor(Date.now() / 1000) + 365 * 24 * 3600,
|
|
83
|
+
updatedAt: new Date().toISOString()
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
memoryEntitlements.set(`jti:${resolvedJti}`, record);
|
|
87
|
+
if (cleanEmail) {
|
|
88
|
+
memoryEntitlements.set(`email:${cleanEmail}`, record);
|
|
89
|
+
memoryEntitlements.set(`email:${cleanEmail}:${plan}`, record);
|
|
90
|
+
}
|
|
91
|
+
if (customerId) {
|
|
92
|
+
memoryEntitlements.set(`cust:${customerId}`, record);
|
|
93
|
+
memoryEntitlements.set(`cust:${customerId}:${plan}`, record);
|
|
94
|
+
}
|
|
95
|
+
return record;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function updateEntitlementStatus({
|
|
99
|
+
jti,
|
|
100
|
+
email,
|
|
101
|
+
customerId,
|
|
102
|
+
status = "revoked",
|
|
103
|
+
targetPlan = null,
|
|
104
|
+
allowClearRevoked = false
|
|
105
|
+
}) {
|
|
106
|
+
let updated = false;
|
|
107
|
+
const cleanEmail = email ? String(email).toLowerCase().trim() : "";
|
|
108
|
+
|
|
109
|
+
for (const [, rec] of memoryEntitlements.entries()) {
|
|
110
|
+
const matchesIdentity =
|
|
111
|
+
(jti && rec.jti === jti) ||
|
|
112
|
+
(cleanEmail && rec.email === cleanEmail) ||
|
|
113
|
+
(customerId && rec.customerId === customerId);
|
|
114
|
+
if (!matchesIdentity) continue;
|
|
115
|
+
if (targetPlan && rec.plan && rec.plan !== targetPlan) continue;
|
|
116
|
+
|
|
117
|
+
// Never allow a subscription 'active' event to clear a refund ('revoked') or dispute ('suspended')
|
|
118
|
+
if (
|
|
119
|
+
status === "active" &&
|
|
120
|
+
!allowClearRevoked &&
|
|
121
|
+
(rec.status === "revoked" || rec.status === "suspended")
|
|
122
|
+
) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
rec.status = status;
|
|
127
|
+
rec.updatedAt = new Date().toISOString();
|
|
128
|
+
updated = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!updated && (jti || cleanEmail || customerId) && status !== "active") {
|
|
132
|
+
upsertEntitlement({
|
|
133
|
+
jti: jti || `revoked_${Date.now()}`,
|
|
134
|
+
email: cleanEmail,
|
|
135
|
+
customerId,
|
|
136
|
+
plan: targetPlan || "launch_pass",
|
|
137
|
+
status
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return updated;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function getEntitlement({ jti, email, customerId, plan = null } = {}) {
|
|
144
|
+
if (jti && memoryEntitlements.has(`jti:${jti}`)) {
|
|
145
|
+
return memoryEntitlements.get(`jti:${jti}`);
|
|
146
|
+
}
|
|
147
|
+
if (email) {
|
|
148
|
+
const cleanEmail = String(email).toLowerCase().trim();
|
|
149
|
+
if (plan && memoryEntitlements.has(`email:${cleanEmail}:${plan}`)) {
|
|
150
|
+
return memoryEntitlements.get(`email:${cleanEmail}:${plan}`);
|
|
151
|
+
}
|
|
152
|
+
if (memoryEntitlements.has(`email:${cleanEmail}`)) {
|
|
153
|
+
const rec = memoryEntitlements.get(`email:${cleanEmail}`);
|
|
154
|
+
if (!plan || !rec.plan || rec.plan === plan || rec.status === "revoked" || rec.status === "suspended") {
|
|
155
|
+
return rec;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (customerId) {
|
|
160
|
+
if (plan && memoryEntitlements.has(`cust:${customerId}:${plan}`)) {
|
|
161
|
+
return memoryEntitlements.get(`cust:${customerId}:${plan}`);
|
|
162
|
+
}
|
|
163
|
+
if (memoryEntitlements.has(`cust:${customerId}`)) {
|
|
164
|
+
const rec = memoryEntitlements.get(`cust:${customerId}`);
|
|
165
|
+
if (!plan || !rec.plan || rec.plan === plan || rec.status === "revoked" || rec.status === "suspended") {
|
|
166
|
+
return rec;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isEntitlementRevoked({ jti, email, customerId, plan = null } = {}) {
|
|
174
|
+
const rec = getEntitlement({ jti, email, customerId, plan });
|
|
175
|
+
if (!rec) return false;
|
|
176
|
+
return rec.status !== "active";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const stripeCustomerStatusCache = new Map(); // key -> { status, checkedAt }
|
|
180
|
+
|
|
181
|
+
export function clearAuthStateForTests() {
|
|
182
|
+
otpChallengeState.clear();
|
|
183
|
+
memoryEntitlements.clear();
|
|
184
|
+
processedWebhookEvents.clear();
|
|
185
|
+
stripeCustomerStatusCache.clear();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── CLI CREDENTIALS & LICENSE VERIFICATION ──────────────────────────────────
|
|
189
|
+
|
|
190
|
+
export function loadLocalCredentials() {
|
|
191
|
+
if (process.env.POSTBUILD_LICENSE_KEY) {
|
|
192
|
+
return {
|
|
193
|
+
token: process.env.POSTBUILD_LICENSE_KEY.trim(),
|
|
194
|
+
email: "cli@postbuild.dev",
|
|
195
|
+
plan: "launch_pass"
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const credPath = path.join(os.homedir(), ".postbuild", "credentials.json");
|
|
200
|
+
if (fs.existsSync(credPath)) {
|
|
201
|
+
const data = JSON.parse(fs.readFileSync(credPath, "utf8"));
|
|
202
|
+
if (data && data.token) return data;
|
|
203
|
+
}
|
|
204
|
+
} catch {}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function verifyCliLicense({ domain = "postbuild.dev", token = "" } = {}) {
|
|
209
|
+
const candidateToken = (token || "").trim() || loadLocalCredentials()?.token || "";
|
|
210
|
+
|
|
211
|
+
if (candidateToken && candidateToken.startsWith("pb_live_")) {
|
|
212
|
+
// 1. If running on the server (where signing secret is present), verify signature & revocation directly
|
|
213
|
+
try {
|
|
214
|
+
const localCheck = verifyLicenseToken(candidateToken);
|
|
215
|
+
if (localCheck.valid) {
|
|
216
|
+
return {
|
|
217
|
+
valid: true,
|
|
218
|
+
email: localCheck.email,
|
|
219
|
+
plan: localCheck.plan,
|
|
220
|
+
domain: localCheck.domain,
|
|
221
|
+
inbox: localCheck.inbox,
|
|
222
|
+
jti: localCheck.jti,
|
|
223
|
+
token: candidateToken
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
valid: false,
|
|
228
|
+
reason: localCheck.error || "INVALID_OR_REVOKED_LICENSE",
|
|
229
|
+
checkoutUrl: `https://www.postbuild.dev/?domain=${encodeURIComponent(domain)}`,
|
|
230
|
+
webUrl: `https://www.postbuild.dev/?domain=${encodeURIComponent(domain)}`,
|
|
231
|
+
message: localCheck.error || "Invalid or revoked pb_live_ license key."
|
|
232
|
+
};
|
|
233
|
+
} catch {
|
|
234
|
+
// 2. When running on a user's machine via CLI (without server secret), verify against https://www.postbuild.dev/api/auth
|
|
235
|
+
try {
|
|
236
|
+
const resp = await fetch("https://www.postbuild.dev/api/auth", {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: {
|
|
239
|
+
"Content-Type": "application/json",
|
|
240
|
+
Authorization: `Bearer ${candidateToken}`
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify({}),
|
|
243
|
+
signal: AbortSignal.timeout(5000)
|
|
244
|
+
});
|
|
245
|
+
if (resp.ok) {
|
|
246
|
+
const data = await resp.json();
|
|
247
|
+
if (data && data.ok && data.authenticated) {
|
|
248
|
+
return {
|
|
249
|
+
valid: true,
|
|
250
|
+
email: data.email,
|
|
251
|
+
plan: data.plan,
|
|
252
|
+
domain: data.domain,
|
|
253
|
+
inbox: data.inbox,
|
|
254
|
+
token: candidateToken
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch {}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
valid: false,
|
|
264
|
+
reason: "PRO_LICENSE_REQUIRED",
|
|
265
|
+
checkoutUrl: `https://www.postbuild.dev/?domain=${encodeURIComponent(domain)}`,
|
|
266
|
+
webUrl: `https://www.postbuild.dev/?domain=${encodeURIComponent(domain)}`,
|
|
267
|
+
message:
|
|
268
|
+
"A verified postbuild Launch Pass ($29) or Studio ($49/mo) license (pb_live_...) is required to unlock 25 Programmatic SEO HTML pages, 85-directory dispatch, and @postbuild.dev inbox verification."
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── CRYPTOGRAPHIC TOKEN SIGNING & VERIFICATION ──────────────────────────────
|
|
273
|
+
|
|
274
|
+
export function signLicenseToken({
|
|
275
|
+
email,
|
|
276
|
+
plan = "launch_pass",
|
|
277
|
+
domain = "postbuild.dev",
|
|
278
|
+
jti = "",
|
|
279
|
+
customerId = null,
|
|
280
|
+
ttlSeconds = 365 * 24 * 3600
|
|
281
|
+
}) {
|
|
282
|
+
const secret = requireAuthSecret();
|
|
283
|
+
const cleanEmail = (email || "").toLowerCase().trim();
|
|
284
|
+
const cleanDomain =
|
|
285
|
+
String(domain || "postbuild.dev")
|
|
286
|
+
.toLowerCase()
|
|
287
|
+
.trim()
|
|
288
|
+
.replace(/^https?:\/\//, "")
|
|
289
|
+
.replace(/^www\./, "")
|
|
290
|
+
.split("/")[0] || "postbuild.dev";
|
|
291
|
+
const now = Math.floor(Date.now() / 1000);
|
|
292
|
+
const exp = now + ttlSeconds;
|
|
293
|
+
const resolvedJti = jti || crypto.randomUUID();
|
|
294
|
+
const inbox = getBotInboxForDomain(cleanDomain, cleanEmail);
|
|
295
|
+
|
|
296
|
+
upsertEntitlement({
|
|
297
|
+
jti: resolvedJti,
|
|
298
|
+
email: cleanEmail,
|
|
299
|
+
customerId,
|
|
300
|
+
plan,
|
|
301
|
+
domain: cleanDomain,
|
|
302
|
+
status: "active",
|
|
303
|
+
expiresAt: exp
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const payload = {
|
|
307
|
+
jti: resolvedJti,
|
|
308
|
+
email: cleanEmail,
|
|
309
|
+
customerId: customerId || null,
|
|
310
|
+
plan,
|
|
311
|
+
domain: cleanDomain,
|
|
312
|
+
inbox,
|
|
313
|
+
iat: now,
|
|
314
|
+
exp
|
|
315
|
+
};
|
|
316
|
+
const base64Payload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
317
|
+
const sig = crypto.createHmac("sha256", secret).update(base64Payload).digest("base64url").slice(0, 32);
|
|
318
|
+
return `pb_live_${base64Payload}.${sig}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function verifyLicenseToken(token) {
|
|
322
|
+
if (!token || typeof token !== "string" || !token.startsWith("pb_live_")) {
|
|
323
|
+
return { valid: false };
|
|
324
|
+
}
|
|
325
|
+
const secret = requireAuthSecret();
|
|
326
|
+
const raw = token.slice("pb_live_".length);
|
|
327
|
+
const dotIdx = raw.indexOf(".");
|
|
328
|
+
if (dotIdx <= 0) return { valid: false };
|
|
329
|
+
|
|
330
|
+
const base64Payload = raw.slice(0, dotIdx);
|
|
331
|
+
const sig = raw.slice(dotIdx + 1);
|
|
332
|
+
if (!base64Payload || !sig) return { valid: false };
|
|
333
|
+
|
|
334
|
+
const fullHmac = crypto.createHmac("sha256", secret).update(base64Payload).digest("base64url");
|
|
335
|
+
const expectedSig32 = fullHmac.slice(0, 32);
|
|
336
|
+
const expectedSig24 = fullHmac.slice(0, 24);
|
|
337
|
+
|
|
338
|
+
const sigValid =
|
|
339
|
+
timingSafeStringEqual(sig, expectedSig32) || timingSafeStringEqual(sig, expectedSig24);
|
|
340
|
+
if (!sigValid) {
|
|
341
|
+
return { valid: false, error: "Invalid license signature" };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const payload = JSON.parse(Buffer.from(base64Payload, "base64url").toString("utf8"));
|
|
346
|
+
const now = Math.floor(Date.now() / 1000);
|
|
347
|
+
if (payload.exp && now > Number(payload.exp)) {
|
|
348
|
+
return { valid: false, error: "License token has expired" };
|
|
349
|
+
}
|
|
350
|
+
if (
|
|
351
|
+
isEntitlementRevoked({
|
|
352
|
+
jti: payload.jti,
|
|
353
|
+
email: payload.email,
|
|
354
|
+
customerId: payload.customerId,
|
|
355
|
+
plan: payload.plan
|
|
356
|
+
})
|
|
357
|
+
) {
|
|
358
|
+
return { valid: false, error: "License entitlement has been revoked or refunded" };
|
|
359
|
+
}
|
|
360
|
+
if (!payload.inbox && payload.domain) {
|
|
361
|
+
payload.inbox = getBotInboxForDomain(payload.domain, payload.email);
|
|
362
|
+
}
|
|
363
|
+
return { valid: true, ...payload };
|
|
364
|
+
} catch {
|
|
365
|
+
return { valid: false };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── HARDENED EMAIL OTP CHALLENGE (MAX 5 ATTEMPTS, SINGLE-USE, TIMING-SAFE) ─
|
|
370
|
+
|
|
371
|
+
function deriveOtpCipherKey(secret) {
|
|
372
|
+
return crypto.createHmac("sha256", secret).update("postbuild:otp:aes-256-gcm:v2").digest();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function createSignedOtpChallenge(
|
|
376
|
+
email,
|
|
377
|
+
otpCode,
|
|
378
|
+
plan = "launch_pass",
|
|
379
|
+
domain = "postbuild.dev",
|
|
380
|
+
options = {}
|
|
381
|
+
) {
|
|
382
|
+
const resolvedOptions =
|
|
383
|
+
domain && typeof domain === "object" ? domain : options || {};
|
|
384
|
+
const rawDomain =
|
|
385
|
+
domain && typeof domain === "object" ? "postbuild.dev" : domain;
|
|
386
|
+
|
|
387
|
+
const secret = requireAuthSecret();
|
|
388
|
+
const cid = crypto.randomBytes(12).toString("base64url");
|
|
389
|
+
const exp = Math.floor(Date.now() / 1000) + 600; // 10 minutes
|
|
390
|
+
const cleanEmail = email.toLowerCase().trim();
|
|
391
|
+
const cleanDomain =
|
|
392
|
+
String(rawDomain || "postbuild.dev")
|
|
393
|
+
.toLowerCase()
|
|
394
|
+
.trim()
|
|
395
|
+
.replace(/^https?:\/\//, "")
|
|
396
|
+
.replace(/^www\./, "")
|
|
397
|
+
.split("/")[0] || "postbuild.dev";
|
|
398
|
+
const customerId = resolvedOptions.customerId || "";
|
|
399
|
+
|
|
400
|
+
const payloadObj = {
|
|
401
|
+
cid,
|
|
402
|
+
email: cleanEmail,
|
|
403
|
+
code: String(otpCode).trim(),
|
|
404
|
+
plan,
|
|
405
|
+
domain: cleanDomain,
|
|
406
|
+
customerId,
|
|
407
|
+
exp,
|
|
408
|
+
dummy: Boolean(resolvedOptions.dummy)
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// Pad plaintext to fixed 320-byte length so real and dummy challenges have identical ciphertext length
|
|
412
|
+
const plaintext = JSON.stringify(payloadObj).padEnd(320, " ");
|
|
413
|
+
const iv = crypto.randomBytes(12);
|
|
414
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", deriveOtpCipherKey(secret), iv);
|
|
415
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
416
|
+
const authTag = cipher.getAuthTag();
|
|
417
|
+
|
|
418
|
+
otpChallengeState.set(cid, {
|
|
419
|
+
attempts: 0,
|
|
420
|
+
used: false,
|
|
421
|
+
expiresAt: exp * 1000
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return `${iv.toString("base64url")}.${encrypted.toString("base64url")}.${authTag.toString("base64url")}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function verifySignedOtpChallenge(challengeToken, otpCode) {
|
|
428
|
+
if (!challengeToken || !otpCode) return { valid: false, error: "Missing verification code." };
|
|
429
|
+
const secret = requireAuthSecret();
|
|
430
|
+
const parts = String(challengeToken).split(".");
|
|
431
|
+
if (parts.length !== 3) return { valid: false, error: "Invalid verification challenge." };
|
|
432
|
+
const [ivB64, encB64, tagB64] = parts;
|
|
433
|
+
if (!ivB64 || !encB64 || !tagB64) return { valid: false, error: "Invalid verification challenge." };
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const iv = Buffer.from(ivB64, "base64url");
|
|
437
|
+
const encrypted = Buffer.from(encB64, "base64url");
|
|
438
|
+
const authTag = Buffer.from(tagB64, "base64url");
|
|
439
|
+
if (iv.length !== 12 || authTag.length !== 16) {
|
|
440
|
+
return { valid: false, error: "Invalid verification challenge." };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", deriveOtpCipherKey(secret), iv);
|
|
444
|
+
decipher.setAuthTag(authTag);
|
|
445
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8").trim();
|
|
446
|
+
const parsed = JSON.parse(decrypted);
|
|
447
|
+
|
|
448
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
449
|
+
if (!parsed.exp || nowSec > parsed.exp) {
|
|
450
|
+
return { valid: false, error: "Verification code expired. Please request a new code." };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const cid = parsed.cid || encB64.slice(0, 16);
|
|
454
|
+
let state = otpChallengeState.get(cid);
|
|
455
|
+
if (!state) {
|
|
456
|
+
state = { attempts: 0, used: false, expiresAt: parsed.exp * 1000 };
|
|
457
|
+
otpChallengeState.set(cid, state);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (state.used) {
|
|
461
|
+
return { valid: false, error: "This verification code has already been used." };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
state.attempts += 1;
|
|
465
|
+
if (state.attempts > 5) {
|
|
466
|
+
return { valid: false, error: "Maximum verification attempts (5) exceeded. Please request a new code." };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!timingSafeStringEqual(String(otpCode).trim(), String(parsed.code || "").trim())) {
|
|
470
|
+
return { valid: false, error: "Invalid 6-digit verification code." };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Never allow a dummy enumeration-resistance challenge to mint a real license
|
|
474
|
+
if (parsed.dummy === true || parsed.plan === "none") {
|
|
475
|
+
return { valid: false, error: "Invalid 6-digit verification code." };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
state.used = true;
|
|
479
|
+
return {
|
|
480
|
+
valid: true,
|
|
481
|
+
email: parsed.email,
|
|
482
|
+
plan: parsed.plan || "launch_pass",
|
|
483
|
+
domain: parsed.domain || "postbuild.dev",
|
|
484
|
+
customerId: parsed.customerId || null
|
|
485
|
+
};
|
|
486
|
+
} catch {
|
|
487
|
+
return { valid: false, error: "Invalid verification challenge." };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function getFounderEmailsSet() {
|
|
492
|
+
return new Set(
|
|
493
|
+
[
|
|
494
|
+
"bennett@postbuild.dev",
|
|
495
|
+
(process.env.ADMIN_EMAIL || "").toLowerCase().trim(),
|
|
496
|
+
(process.env.ADMIN_FORWARD_EMAIL || "").toLowerCase().trim(),
|
|
497
|
+
...(process.env.POSTBUILD_OPERATOR_EMAILS || "")
|
|
498
|
+
.split(",")
|
|
499
|
+
.map((s) => s.toLowerCase().trim())
|
|
500
|
+
].filter(Boolean)
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function sendLoginOtpViaResend(email, otpCode) {
|
|
505
|
+
const resendKey = (process.env.RESEND_API_KEY || "").trim();
|
|
506
|
+
if (!resendKey) {
|
|
507
|
+
return { sent: false, reason: "missing_resend_key" };
|
|
508
|
+
}
|
|
509
|
+
const cleanEmail = String(email || "").toLowerCase().trim();
|
|
510
|
+
const recipients = [cleanEmail];
|
|
511
|
+
if (cleanEmail === "bennett@postbuild.dev") {
|
|
512
|
+
const founderForward = (process.env.ADMIN_FORWARD_EMAIL || process.env.ADMIN_EMAIL || "").toLowerCase().trim();
|
|
513
|
+
if (founderForward && !recipients.includes(founderForward)) {
|
|
514
|
+
recipients.push(founderForward);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
try {
|
|
518
|
+
const resp = await fetch("https://api.resend.com/emails", {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: {
|
|
521
|
+
Authorization: `Bearer ${resendKey}`,
|
|
522
|
+
"Content-Type": "application/json"
|
|
523
|
+
},
|
|
524
|
+
body: JSON.stringify({
|
|
525
|
+
from: "postbuild <login@postbuild.dev>",
|
|
526
|
+
to: recipients,
|
|
527
|
+
subject: `${otpCode} is your postbuild verification code`,
|
|
528
|
+
html: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif; max-width: 520px; margin: 0 auto; padding: 28px; border: 1px solid #e2e8f0; border-radius: 14px; color: #0f172a;">
|
|
529
|
+
<h2 style="margin: 0 0 8px; font-size: 20px;">postbuild · Account Verification</h2>
|
|
530
|
+
<p style="color: #475569; font-size: 14px; line-height: 1.6;">Enter this 6-digit verification code on <strong>www.postbuild.dev</strong> within 10 minutes to issue your license key and manage your billing:</p>
|
|
531
|
+
<div style="background: #ecfdf5; border: 1px solid #6ee7b7; color: #047857; font-family: monospace; font-size: 28px; font-weight: 800; letter-spacing: 6px; text-align: center; padding: 14px; border-radius: 10px; margin: 18px 0;">${otpCode}</div>
|
|
532
|
+
<p style="color: #64748b; font-size: 12px; line-height: 1.5;">For security, your <code>pb_live_</code> license key is issued only after you enter this code.</p>
|
|
533
|
+
</div>`
|
|
534
|
+
})
|
|
535
|
+
});
|
|
536
|
+
if (!resp.ok) {
|
|
537
|
+
const errText = await resp.text().catch(() => "");
|
|
538
|
+
console.error("[auth/resend] Failed to send OTP email:", resp.status, errText);
|
|
539
|
+
}
|
|
540
|
+
return { sent: resp.ok };
|
|
541
|
+
} catch (err) {
|
|
542
|
+
console.error("[auth/resend] Error sending OTP email:", err?.message || err);
|
|
543
|
+
return { sent: false };
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function escapeHtml(str) {
|
|
548
|
+
return String(str || "")
|
|
549
|
+
.replace(/&/g, "&")
|
|
550
|
+
.replace(/</g, "<")
|
|
551
|
+
.replace(/>/g, ">")
|
|
552
|
+
.replace(/"/g, """)
|
|
553
|
+
.replace(/'/g, "'");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function sendLicenseFulfillmentEmail({ email, licenseToken, planId, targetDomain }) {
|
|
557
|
+
const resendKey = (process.env.RESEND_API_KEY || "").trim();
|
|
558
|
+
if (!resendKey || !email) return { sent: false };
|
|
559
|
+
const safeDomain = escapeHtml(targetDomain || "postbuild.dev");
|
|
560
|
+
const inbox = escapeHtml(getBotInboxForDomain(targetDomain, email));
|
|
561
|
+
const safeToken = escapeHtml(licenseToken);
|
|
562
|
+
try {
|
|
563
|
+
const resp = await fetch("https://api.resend.com/emails", {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: {
|
|
566
|
+
Authorization: `Bearer ${resendKey}`,
|
|
567
|
+
"Content-Type": "application/json"
|
|
568
|
+
},
|
|
569
|
+
body: JSON.stringify({
|
|
570
|
+
from: "postbuild <login@postbuild.dev>",
|
|
571
|
+
to: [email],
|
|
572
|
+
subject: `Your postbuild (${planId === "studio_monthly" ? "Studio" : "Launch Pass"}) License Key for ${targetDomain}`,
|
|
573
|
+
html: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, sans-serif; max-width: 540px; margin: 0 auto; padding: 28px; border: 1px solid #e2e8f0; border-radius: 14px; color: #0f172a;">
|
|
574
|
+
<h2 style="margin: 0 0 8px; font-size: 20px;">postbuild · License Activated</h2>
|
|
575
|
+
<p style="color: #475569; font-size: 14px; line-height: 1.6;">Your <strong>${planId === "studio_monthly" ? "postbuild Studio" : "postbuild Launch Pass"}</strong> for <strong>${safeDomain}</strong> is active.</p>
|
|
576
|
+
<p style="color: #475569; font-size: 13px; margin-bottom: 6px;"><strong>Dedicated Verification Inbox:</strong> <code>${inbox}</code></p>
|
|
577
|
+
<p style="color: #475569; font-size: 13px; margin-bottom: 6px;"><strong>CLI & Web License Key:</strong></p>
|
|
578
|
+
<pre style="background: #f8fafc; border: 1px solid #e2e8f0; padding: 12px; border-radius: 8px; font-size: 11px; overflow-x: auto;">${safeToken}</pre>
|
|
579
|
+
<p style="color: #475569; font-size: 13px; margin-top: 12px;">Authenticate your CLI with: <code>npx git-paid login ${safeToken}</code></p>
|
|
580
|
+
</div>`
|
|
581
|
+
})
|
|
582
|
+
});
|
|
583
|
+
return { sent: resp.ok };
|
|
584
|
+
} catch {
|
|
585
|
+
return { sent: false };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export async function checkStripeCustomerStatusCached(verified) {
|
|
590
|
+
if (!verified || !verified.valid) return "active";
|
|
591
|
+
const baseId = verified.customerId || verified.email || "";
|
|
592
|
+
if (!baseId) return "active";
|
|
593
|
+
const cacheKey = `${baseId}:${verified.plan || "launch_pass"}`;
|
|
594
|
+
|
|
595
|
+
const now = Date.now();
|
|
596
|
+
const cached = stripeCustomerStatusCache.get(cacheKey);
|
|
597
|
+
if (cached && now - cached.checkedAt < 60000) {
|
|
598
|
+
return cached.status;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let stripe = null;
|
|
602
|
+
try {
|
|
603
|
+
const stripeMod = await import("./stripe.js");
|
|
604
|
+
stripe = stripeMod.getStripeClient();
|
|
605
|
+
} catch {}
|
|
606
|
+
if (!stripe) return "active";
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
let cust = null;
|
|
610
|
+
if (verified.customerId) {
|
|
611
|
+
cust = await stripe.customers.retrieve(verified.customerId);
|
|
612
|
+
} else if (verified.email) {
|
|
613
|
+
const list = await stripe.customers.list({ email: verified.email, limit: 1 });
|
|
614
|
+
cust = list.data?.[0] || null;
|
|
615
|
+
}
|
|
616
|
+
if (cust && !cust.deleted && cust.metadata?.pb_status) {
|
|
617
|
+
const pbStatus = cust.metadata.pb_status;
|
|
618
|
+
if (pbStatus === "revoked" || pbStatus === "suspended") {
|
|
619
|
+
stripeCustomerStatusCache.set(cacheKey, { status: pbStatus, checkedAt: now });
|
|
620
|
+
updateEntitlementStatus({
|
|
621
|
+
customerId: cust.id,
|
|
622
|
+
email: verified.email,
|
|
623
|
+
status: pbStatus
|
|
624
|
+
});
|
|
625
|
+
return pbStatus;
|
|
626
|
+
}
|
|
627
|
+
if (pbStatus === "cancelled" && verified.plan === "studio_monthly") {
|
|
628
|
+
stripeCustomerStatusCache.set(cacheKey, { status: "cancelled", checkedAt: now });
|
|
629
|
+
updateEntitlementStatus({
|
|
630
|
+
customerId: cust.id,
|
|
631
|
+
email: verified.email,
|
|
632
|
+
status: "cancelled",
|
|
633
|
+
targetPlan: "studio_monthly"
|
|
634
|
+
});
|
|
635
|
+
return "cancelled";
|
|
636
|
+
}
|
|
637
|
+
stripeCustomerStatusCache.set(cacheKey, { status: "active", checkedAt: now });
|
|
638
|
+
} else {
|
|
639
|
+
stripeCustomerStatusCache.set(cacheKey, { status: "active", checkedAt: now });
|
|
640
|
+
}
|
|
641
|
+
} catch {}
|
|
642
|
+
|
|
643
|
+
return "active";
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function authenticateUser({ email, token, sessionId, otpCode, challengeToken }) {
|
|
647
|
+
const founderEmails = getFounderEmailsSet();
|
|
648
|
+
|
|
649
|
+
// 1. Verify existing pb_live_ token via HMAC-SHA256 + revocation check + 60s Stripe Customer pb_status check
|
|
650
|
+
if (token && token.startsWith("pb_live_")) {
|
|
651
|
+
const verified = verifyLicenseToken(token);
|
|
652
|
+
if (verified.valid) {
|
|
653
|
+
const remoteStatus = await checkStripeCustomerStatusCached(verified);
|
|
654
|
+
if (remoteStatus !== "active") {
|
|
655
|
+
return {
|
|
656
|
+
ok: false,
|
|
657
|
+
authenticated: false,
|
|
658
|
+
error: "License entitlement has been revoked, refunded, or cancelled."
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
ok: true,
|
|
663
|
+
authenticated: true,
|
|
664
|
+
email: verified.email,
|
|
665
|
+
customerId: verified.customerId || null,
|
|
666
|
+
plan: verified.plan,
|
|
667
|
+
domain: verified.domain,
|
|
668
|
+
inbox: verified.inbox,
|
|
669
|
+
isFounder: founderEmails.has(String(verified.email || "").toLowerCase().trim()),
|
|
670
|
+
token
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
return { ok: false, authenticated: false, error: verified.error || "Invalid pb_live_ license key signature." };
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// 2. Step 2 of Email OTP verification — issues pb_live_ license ONLY after OTP check passes
|
|
677
|
+
if (otpCode && challengeToken) {
|
|
678
|
+
const check = verifySignedOtpChallenge(challengeToken, otpCode);
|
|
679
|
+
if (!check.valid) {
|
|
680
|
+
return { ok: false, authenticated: false, error: check.error || "Invalid verification code." };
|
|
681
|
+
}
|
|
682
|
+
if (isEntitlementRevoked({ email: check.email, customerId: check.customerId, plan: check.plan })) {
|
|
683
|
+
return { ok: false, authenticated: false, error: "This license entitlement has been revoked or refunded." };
|
|
684
|
+
}
|
|
685
|
+
const existingEnt = getEntitlement({ email: check.email, customerId: check.customerId, plan: check.plan });
|
|
686
|
+
const plan = existingEnt?.plan || check.plan || "launch_pass";
|
|
687
|
+
const domain = existingEnt?.domain || check.domain || "postbuild.dev";
|
|
688
|
+
const customerId = existingEnt?.customerId || check.customerId || null;
|
|
689
|
+
const issued = signLicenseToken({ email: check.email, plan, domain, customerId });
|
|
690
|
+
return {
|
|
691
|
+
ok: true,
|
|
692
|
+
authenticated: true,
|
|
693
|
+
email: check.email,
|
|
694
|
+
customerId,
|
|
695
|
+
plan,
|
|
696
|
+
domain,
|
|
697
|
+
inbox: getBotInboxForDomain(domain, check.email),
|
|
698
|
+
isFounder: founderEmails.has(String(check.email || "").toLowerCase().trim()),
|
|
699
|
+
token: issued
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
let stripe = null;
|
|
704
|
+
try {
|
|
705
|
+
const stripeMod = await import("./stripe.js");
|
|
706
|
+
stripe = stripeMod.getStripeClient();
|
|
707
|
+
} catch {}
|
|
708
|
+
|
|
709
|
+
// 3. Stripe Checkout return session_id verification
|
|
710
|
+
if (sessionId && stripe) {
|
|
711
|
+
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["customer"] });
|
|
712
|
+
if (session && session.payment_status !== "unpaid") {
|
|
713
|
+
const custEmail = (session.customer_details?.email || email || "").toLowerCase().trim();
|
|
714
|
+
// Never allow an unverified checkout email claiming @postbuild.dev or operator status to bypass Email OTP
|
|
715
|
+
if (custEmail.endsWith("@postbuild.dev") || founderEmails.has(custEmail)) {
|
|
716
|
+
return {
|
|
717
|
+
ok: false,
|
|
718
|
+
authenticated: false,
|
|
719
|
+
error: "Operator and @postbuild.dev addresses require 6-digit Email OTP verification to sign in."
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
const plan = session.metadata?.plan_id || (session.mode === "subscription" ? "studio_monthly" : "launch_pass");
|
|
723
|
+
const domain = session.client_reference_id || session.metadata?.target_domain || "";
|
|
724
|
+
if (!domain || domain === "postbuild.dev") {
|
|
725
|
+
return {
|
|
726
|
+
ok: false,
|
|
727
|
+
authenticated: false,
|
|
728
|
+
error: "A valid non-reserved project domain is required to issue a license."
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
const customerObj = session.customer && typeof session.customer === "object" ? session.customer : null;
|
|
732
|
+
const customerId = typeof session.customer === "string" ? session.customer : customerObj?.id || null;
|
|
733
|
+
const custStatus = customerObj?.metadata?.pb_status || "active";
|
|
734
|
+
if (custStatus !== "active" || isEntitlementRevoked({ email: custEmail, customerId, plan })) {
|
|
735
|
+
return { ok: false, authenticated: false, error: "This license entitlement has been revoked or refunded." };
|
|
736
|
+
}
|
|
737
|
+
const newToken = signLicenseToken({ email: custEmail, plan, domain, customerId });
|
|
738
|
+
return {
|
|
739
|
+
ok: true,
|
|
740
|
+
authenticated: true,
|
|
741
|
+
email: custEmail,
|
|
742
|
+
customerId,
|
|
743
|
+
plan,
|
|
744
|
+
domain,
|
|
745
|
+
inbox: getBotInboxForDomain(domain, custEmail),
|
|
746
|
+
isFounder: false,
|
|
747
|
+
token: newToken
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// 4. Email OTP request — uniform response to prevent customer email enumeration
|
|
753
|
+
const cleanEmail = (email || "").toLowerCase().trim();
|
|
754
|
+
if (!cleanEmail || !cleanEmail.includes("@")) {
|
|
755
|
+
return { ok: false, authenticated: false, error: "Enter your email address or pb_live_ license key." };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const uniformResponseMessage =
|
|
759
|
+
"If an active Launch Pass or Studio account is associated with that email, a 6-digit verification code has been sent.";
|
|
760
|
+
|
|
761
|
+
let resolvedPlan = "launch_pass";
|
|
762
|
+
let resolvedDomain = "postbuild.dev";
|
|
763
|
+
let resolvedCustomerId = null;
|
|
764
|
+
let hasActivePurchase = false;
|
|
765
|
+
let isExplicitlyRevoked = false;
|
|
766
|
+
let anyCustomerHadPbStatus = false;
|
|
767
|
+
|
|
768
|
+
const localEnt = getEntitlement({ email: cleanEmail });
|
|
769
|
+
if (localEnt && (localEnt.status === "revoked" || localEnt.status === "suspended")) {
|
|
770
|
+
isExplicitlyRevoked = true;
|
|
771
|
+
} else if (localEnt && localEnt.status === "active") {
|
|
772
|
+
hasActivePurchase = true;
|
|
773
|
+
resolvedPlan = localEnt.plan;
|
|
774
|
+
resolvedDomain = localEnt.domain;
|
|
775
|
+
resolvedCustomerId = localEnt.customerId || null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!hasActivePurchase && !isExplicitlyRevoked && stripe) {
|
|
779
|
+
try {
|
|
780
|
+
const customers = await stripe.customers.list({ email: cleanEmail, limit: 10 });
|
|
781
|
+
if (Array.isArray(customers?.data)) {
|
|
782
|
+
for (const cust of customers.data) {
|
|
783
|
+
const pbStatus = cust.metadata?.pb_status;
|
|
784
|
+
if (pbStatus) {
|
|
785
|
+
anyCustomerHadPbStatus = true;
|
|
786
|
+
}
|
|
787
|
+
if (pbStatus === "revoked" || pbStatus === "suspended") {
|
|
788
|
+
// Do not break early in case a legitimate active customerId exists for this email
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
if (pbStatus === "cancelled") {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (!pbStatus || pbStatus === "active") {
|
|
795
|
+
hasActivePurchase = true;
|
|
796
|
+
resolvedCustomerId = cust.id;
|
|
797
|
+
resolvedPlan = cust.metadata?.pb_plan || "launch_pass";
|
|
798
|
+
resolvedDomain = cust.metadata?.pb_domain || "postbuild.dev";
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
} catch {}
|
|
804
|
+
|
|
805
|
+
// Skip session fallback if a customer record already carries an explicit pb_status (e.g. cancelled/revoked)
|
|
806
|
+
if (!hasActivePurchase && !isExplicitlyRevoked && !anyCustomerHadPbStatus) {
|
|
807
|
+
try {
|
|
808
|
+
const sessions = await stripe.checkout.sessions.list({ limit: 100 });
|
|
809
|
+
const matchingSessions = (sessions?.data || []).filter(
|
|
810
|
+
(s) =>
|
|
811
|
+
s.payment_status !== "unpaid" &&
|
|
812
|
+
(s.customer_details?.email || s.customer_email || "").toLowerCase().trim() === cleanEmail
|
|
813
|
+
);
|
|
814
|
+
const preferred =
|
|
815
|
+
matchingSessions.find((s) => s.mode === "payment" || s.metadata?.plan_id === "launch_pass") ||
|
|
816
|
+
matchingSessions[0];
|
|
817
|
+
if (preferred) {
|
|
818
|
+
hasActivePurchase = true;
|
|
819
|
+
resolvedPlan =
|
|
820
|
+
preferred.metadata?.plan_id || (preferred.mode === "subscription" ? "studio_monthly" : "launch_pass");
|
|
821
|
+
resolvedDomain = preferred.client_reference_id || preferred.metadata?.target_domain || "postbuild.dev";
|
|
822
|
+
resolvedCustomerId =
|
|
823
|
+
(typeof preferred.customer === "string" ? preferred.customer : preferred.customer?.id) ||
|
|
824
|
+
resolvedCustomerId;
|
|
825
|
+
}
|
|
826
|
+
} catch {}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (!hasActivePurchase && !isExplicitlyRevoked && founderEmails.has(cleanEmail)) {
|
|
831
|
+
hasActivePurchase = true;
|
|
832
|
+
resolvedPlan = "studio_monthly";
|
|
833
|
+
resolvedDomain = "postbuild.dev";
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (hasActivePurchase) {
|
|
837
|
+
const generatedOtp = String(crypto.randomInt(100000, 1000000));
|
|
838
|
+
const challenge = createSignedOtpChallenge(cleanEmail, generatedOtp, resolvedPlan, resolvedDomain, {
|
|
839
|
+
customerId: resolvedCustomerId
|
|
840
|
+
});
|
|
841
|
+
await sendLoginOtpViaResend(cleanEmail, generatedOtp);
|
|
842
|
+
return {
|
|
843
|
+
ok: true,
|
|
844
|
+
authenticated: false,
|
|
845
|
+
requiresOtp: true,
|
|
846
|
+
email: cleanEmail,
|
|
847
|
+
challengeToken: challenge,
|
|
848
|
+
message: uniformResponseMessage
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Return uniform challenge shape with an encrypted dummy challenge that can NEVER mint a license
|
|
853
|
+
const dummyOtp = String(crypto.randomInt(100000, 1000000));
|
|
854
|
+
const dummyChallenge = createSignedOtpChallenge(cleanEmail, dummyOtp, "none", "unassigned.invalid", {
|
|
855
|
+
dummy: true
|
|
856
|
+
});
|
|
857
|
+
return {
|
|
858
|
+
ok: true,
|
|
859
|
+
authenticated: false,
|
|
860
|
+
requiresOtp: true,
|
|
861
|
+
email: cleanEmail,
|
|
862
|
+
challengeToken: dummyChallenge,
|
|
863
|
+
message: uniformResponseMessage
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|