← back to AbramsEgo
auto-save: 2026-07-01T17:07:40 (9 files) — build-queue/tasks/29-failure-drills.md build-queue/tasks/30-research-androoagi.md data/local-fleet.json package-lock.json package.json
af660fba8ddd4612bcf0737f28c6e9fe6d4a2608 · 2026-07-01 17:07:41 -0700 · Steve Abrams
Files touched
R100 build-queue/tasks/30-research-androoagi.md build-queue/done/30-research-androoagi.mdR100 build-queue/tasks/29-failure-drills.md build-queue/failed/29-failure-drills.mdM data/local-fleet.jsonM package-lock.jsonM package.jsonM public/landing.htmlM server.js
Diff
commit af660fba8ddd4612bcf0737f28c6e9fe6d4a2608
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 1 17:07:41 2026 -0700
auto-save: 2026-07-01T17:07:40 (9 files) — build-queue/tasks/29-failure-drills.md build-queue/tasks/30-research-androoagi.md data/local-fleet.json package-lock.json package.json
---
.../{tasks => done}/30-research-androoagi.md | 0
build-queue/{tasks => failed}/29-failure-drills.md | 0
data/local-fleet.json | 2 +-
package-lock.json | 33 +++-
package.json | 4 +-
public/landing.html | 34 ++++-
server.js | 166 ++++++++++++++++++++-
7 files changed, 231 insertions(+), 8 deletions(-)
diff --git a/build-queue/tasks/30-research-androoagi.md b/build-queue/done/30-research-androoagi.md
similarity index 100%
rename from build-queue/tasks/30-research-androoagi.md
rename to build-queue/done/30-research-androoagi.md
diff --git a/build-queue/tasks/29-failure-drills.md b/build-queue/failed/29-failure-drills.md
similarity index 100%
rename from build-queue/tasks/29-failure-drills.md
rename to build-queue/failed/29-failure-drills.md
diff --git a/data/local-fleet.json b/data/local-fleet.json
index 7d941e5..0759af7 100644
--- a/data/local-fleet.json
+++ b/data/local-fleet.json
@@ -1 +1 @@
-{"fetchedAt":"2026-07-01T22:08:31.307Z","procs":[{"name":"pm2-logrotate","status":"online","cpu":0.2,"memMB":43,"restarts":0,"uptimeMs":3713722,"startedAt":"2026-07-01T21:06:37.585Z"},{"name":"abramsego","status":"online","cpu":0.8,"memMB":63,"restarts":6,"uptimeMs":1620961,"startedAt":"2026-07-01T21:41:30.346Z"},{"name":"api-token-dashboard","status":"online","cpu":0.2,"memMB":52,"restarts":3,"uptimeMs":1990990,"startedAt":"2026-07-01T21:35:20.317Z"}]}
\ No newline at end of file
+{"fetchedAt":"2026-07-02T00:07:34.797Z","procs":[{"name":"api-token-dashboard","status":"online","cpu":0,"memMB":48,"restarts":null,"uptimeMs":4212000,"startedAt":"2026-07-01T22:57:22.797Z"},{"name":"abramsego","status":"stopped","cpu":null,"memMB":null,"restarts":null,"uptimeMs":null,"startedAt":null}]}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 45d9ff3..249d36e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.1.0",
"dependencies": {
"basic-auth": "^2.0.1",
- "express": "^4.22.2"
+ "dotenv": "^17.4.2",
+ "express": "^4.22.2",
+ "stripe": "^22.3.0"
}
},
"node_modules/accepts": {
@@ -189,6 +191,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -814,6 +828,23 @@
"node": ">= 0.8"
}
},
+ "node_modules/stripe": {
+ "version": "22.3.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.3.0.tgz",
+ "integrity": "sha512-ypO6xjVrMWs9SmIMeHr8naCx3dAQ0clxMdUTxn7Ejd7hmY9meBGfE+N4pVHkf9sUNebAHp6uJo6mV3GxDIc2cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
diff --git a/package.json b/package.json
index d83a963..4a7495c 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,8 @@
},
"dependencies": {
"basic-auth": "^2.0.1",
- "express": "^4.22.2"
+ "dotenv": "^17.4.2",
+ "express": "^4.22.2",
+ "stripe": "^22.3.0"
}
}
diff --git a/public/landing.html b/public/landing.html
index a53a67b..a134808 100644
--- a/public/landing.html
+++ b/public/landing.html
@@ -178,7 +178,7 @@
<li>Hosted uptime for the dashboard</li>
<li class="no">Single fleet</li>
</ul>
- <a class="btn" href="#waitlist">Join waitlist</a>
+ <a class="btn" href="#waitlist" data-tier="hosted">Join waitlist</a>
</div>
<div class="tier">
<div class="name">Pro</div>
@@ -192,10 +192,10 @@
<li>Priority support</li>
<li>Custom cost-ledger sources</li>
</ul>
- <a class="btn ghost" href="#waitlist">Talk to us</a>
+ <a class="btn ghost" href="#waitlist" data-tier="pro">Talk to us</a>
</div>
</div>
- <p class="pricing-note">Prices indicative · billing not yet live — join the waitlist and you'll be first to know when checkout opens.</p>
+ <p class="pricing-note" id="pricing-note">Prices indicative · billing not yet live — join the waitlist and you'll be first to know when checkout opens.</p>
</section>
<!-- WAITLIST -->
@@ -241,6 +241,34 @@ const usd = (n)=> n==null? '—' : '$'+Number(n).toFixed(n<1?4:2);
}catch(e){ /* snapshot not ready — leave dashes */ }
})();
+// Stripe checkout rail (TEST mode). If test keys are installed the tier buttons
+// become live checkout buttons; otherwise the note says keys aren't in yet.
+(async function wireCheckout(){
+ const note = $('pricing-note');
+ let st = null;
+ try { st = await (await fetch('/api/stripe/status')).json(); } catch(e){}
+ if (!st || !st.configured) {
+ note.textContent = 'Stripe test keys not yet installed — checkout opens once they land. Join the waitlist to be first.';
+ return;
+ }
+ note.textContent = 'Checkout is in Stripe TEST mode — card 4242 4242 4242 4242 works, no real charges.';
+ document.querySelectorAll('[data-tier]').forEach((btn)=>{
+ btn.textContent = 'Subscribe (test)';
+ btn.addEventListener('click', async (e)=>{
+ e.preventDefault();
+ btn.textContent = '…creating session';
+ try {
+ const r = await fetch('/api/checkout',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({tier:btn.dataset.tier})});
+ const j = await r.json();
+ if (r.ok && j.url) { location.href = j.url; return; }
+ note.textContent = j.error || 'Checkout unavailable — try again.';
+ } catch(err){ note.textContent = 'Network error — try again.'; }
+ btn.textContent = 'Subscribe (test)';
+ });
+ });
+})();
+
// Waitlist capture → POST /api/waitlist
$('wl-form').addEventListener('submit', async (e)=>{
e.preventDefault();
diff --git a/server.js b/server.js
index bffdc0b..3d0e877 100644
--- a/server.js
+++ b/server.js
@@ -44,6 +44,34 @@ const CNCP = process.env.CNCP_BASE || 'http://127.0.0.1:3333';
const KAMATERA = process.env.KAMATERA_HOST || '45.61.58.125';
const COST_LEDGER = path.join(HOME, '.claude', 'cost-ledger.jsonl');
+// --- Stripe TEST-mode checkout rail (engine 1: sell_product) -----------------
+// HARD RAIL: TEST keys only. A live key (sk_live_) means Steve is flipping the
+// switch himself — the money routes REFUSE to boot on it and log loudly.
+// Absent/invalid key → routes answer 503 {error:"stripe not configured"} so the
+// rail is keys-ready without being live. DTD verdict 2026-07-01: Checkout
+// Sessions via POST /api/checkout (Option B), not Payment Links.
+const STRIPE_KEY = (process.env.STRIPE_SECRET_KEY || '').trim();
+const STRIPE_WEBHOOK_SECRET = (process.env.STRIPE_WEBHOOK_SECRET || '').trim();
+let stripe = null;
+let stripeStatus = { configured: false, mode: null, note: 'test keys not yet installed' };
+if (STRIPE_KEY.startsWith('sk_live_')) {
+ console.error('!!! STRIPE_SECRET_KEY is a LIVE key (sk_live_). AbramsEgo is TEST-mode only — REFUSING to boot the money routes. Going live is Steve-gated; remove the live key.');
+ stripeStatus = { configured: false, mode: 'live-key-refused', note: 'LIVE key detected — money routes disabled (TEST mode only)' };
+} else if (STRIPE_KEY.startsWith('sk_test_')) {
+ try {
+ stripe = require('stripe')(STRIPE_KEY);
+ stripeStatus = { configured: true, mode: 'test', note: 'Stripe TEST mode — no real charges' };
+ } catch (e) { console.error('stripe init failed:', e.message); }
+} else if (STRIPE_KEY) {
+ console.error('STRIPE_SECRET_KEY set but does not start sk_test_ — ignoring (TEST keys only).');
+}
+// $19/$49 tiers from public/landing.html; priced inline (price_data) so no
+// dashboard objects are required before test keys even exist.
+const TIERS = {
+ hosted: { label: 'AbramsEgo Hosted', usd: 19 },
+ pro: { label: 'AbramsEgo Pro', usd: 49 },
+};
+
fs.mkdirSync(DATA_DIR, { recursive: true });
// ---------------------------------------------------------------------------
@@ -51,7 +79,9 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
// ---------------------------------------------------------------------------
function sh(cmd, args, timeoutMs = 8000) {
return new Promise((resolve) => {
- execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 1024 * 1024 * 16 }, (err, stdout) => {
+ // SIGKILL on timeout: a `ps` wedged in the kernel proc-table hang ignores
+ // SIGTERM and would otherwise accumulate as a leaked child forever.
+ execFile(cmd, args, { timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 * 16 }, (err, stdout) => {
resolve({ ok: !err, out: (stdout || '').toString(), err: err && err.message });
});
});
@@ -109,13 +139,87 @@ async function collectSystem() {
// daemon crawls under swap pressure, and a timed-out jlist must degrade to the
// cached snapshot instead of a silently dead panel.
const LOCAL_CACHE = path.join(DATA_DIR, 'local-fleet.json');
+
+// Live fallback when pm2's RPC path is wedged. Seen 2026-07-01: a macOS
+// proc-table hang made every multi-pid `ps` block forever, so the daemon's
+// pidusage call never returned and every `pm2 jlist` hung — while the managed
+// apps themselves were fine. They are direct children of the God daemon, so
+// scan them without RPC: pgrep the daemon's children, keep the ones whose args
+// match a dump.pm2 exec path (the daemon's leaked hung-`ps` children don't),
+// and stat each with a SINGLE-pid ps (single-pid ps still works in that hang).
+async function scanLocalFleetByPid() {
+ let daemonPid;
+ try { daemonPid = parseInt(fs.readFileSync(path.join(HOME, '.pm2', 'pm2.pid'), 'utf8'), 10); } catch (e) { return null; }
+ if (!daemonPid) return null;
+ try { process.kill(daemonPid, 0); } catch (e) { return null; } // daemon gone → nothing to scan
+ let dump = [];
+ try { dump = JSON.parse(fs.readFileSync(path.join(HOME, '.pm2', 'dump.pm2'), 'utf8')); } catch (e) { return null; }
+ const known = dump.filter((d) => d.name && d.pm_exec_path);
+ if (!known.length) return null;
+ const kids = await sh('pgrep', ['-fl', '-P', String(daemonPid)], 5000);
+ if (!kids.ok) return null;
+ const procs = [];
+ for (const line of kids.out.split('\n')) {
+ const m = line.match(/^(\d+)\s+(.*)$/);
+ if (!m) continue;
+ const [, pidStr, args] = m;
+ const app = known.find((d) => args.includes(d.pm_exec_path));
+ if (!app) continue;
+ const st = await sh('ps', ['-p', pidStr, '-o', 'rss=,pcpu=,etime='], 5000);
+ const sm = st.ok && st.out.trim().match(/^(\d+)\s+([\d.]+)\s+(\S+)$/);
+ // etime is [[DD-]HH:]MM:SS
+ let uptimeMs = null;
+ if (sm) {
+ const em = sm[3].match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
+ if (em) uptimeMs = (((+(em[1] || 0) * 24 + +(em[2] || 0)) * 60 + +em[3]) * 60 + +em[4]) * 1000;
+ }
+ procs.push({
+ name: app.name, status: 'online',
+ cpu: sm ? parseFloat(sm[2]) : null,
+ memMB: sm ? round(parseInt(sm[1], 10) / 1024, 0) : null,
+ restarts: null,
+ uptimeMs,
+ startedAt: uptimeMs != null ? new Date(Date.now() - uptimeMs).toISOString() : null,
+ });
+ }
+ if (!procs.length) return null;
+ // dump.pm2 apps with no live child are down — show them, don't hide them
+ for (const d of known) {
+ if (!procs.some((p) => p.name === d.name)) {
+ procs.push({ name: d.name, status: 'stopped', cpu: null, memMB: null, restarts: null, uptimeMs: null, startedAt: null });
+ }
+ }
+ return procs;
+}
+
+// After a jlist failure, skip jlist for a cooldown window: every attempt against
+// a wedged daemon leaks one more kernel-hung `ps` child under it (they ignore
+// SIGTERM and pile up), so retrying every 30s cycle actively makes things worse.
+let jlistFailedAt = 0;
+const JLIST_COOLDOWN_MS = 5 * 60 * 1000;
async function collectLocalFleet() {
const pm2Bin = path.join(HOME, '.npm-global', 'bin', 'pm2');
- const r = await sh(pm2Bin, ['jlist'], 20000);
+ const inCooldown = Date.now() - jlistFailedAt < JLIST_COOLDOWN_MS;
+ const r = inCooldown
+ ? { ok: false, out: '', err: 'jlist cooldown after recent failure' }
+ : await sh(pm2Bin, ['jlist'], 20000);
let list = null;
if (r.ok) { try { list = JSON.parse(r.out); } catch (e) { /* fall through to cache */ } }
+ if (!inCooldown && !list) jlistFailedAt = Date.now();
+ if (list) jlistFailedAt = 0;
if (!list) {
const err = r.ok ? 'pm2 parse' : (r.err || 'pm2 unavailable');
+ // RPC failed but the fleet may be fine — try the daemon-children pid scan.
+ // Live scan data is fresh and true, so it is NOT an error state for the
+ // panel (note still surfaces that RPC is unhealthy).
+ const scanned = await scanLocalFleetByPid();
+ if (scanned) {
+ try { fs.writeFileSync(LOCAL_CACHE, JSON.stringify({ fetchedAt: new Date().toISOString(), procs: scanned })); } catch (e) { /* best-effort */ }
+ return {
+ procs: scanned, up: scanned.filter((p) => p.status === 'online').length,
+ total: scanned.length, note: `pm2 rpc unresponsive (${err}) — live pid scan`,
+ };
+ }
try {
const c = JSON.parse(fs.readFileSync(LOCAL_CACHE, 'utf8'));
return {
@@ -730,6 +834,30 @@ setInterval(() => { buildSnapshot(); }, 30 * 1000);
// ---------------------------------------------------------------------------
const app = express();
app.disable('x-powered-by');
+
+// Stripe webhook FIRST — registered before express.json() so the body stays RAW
+// (signature verification hashes the exact bytes), and before the Basic-Auth
+// gate because Stripe can't send our credentials; the stripe-signature header
+// is the authentication. Unsigned/bad-signature posts are rejected 400.
+app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
+ if (!stripe || !STRIPE_WEBHOOK_SECRET) return res.status(503).json({ error: 'stripe not configured' });
+ let event;
+ try {
+ event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], STRIPE_WEBHOOK_SECRET);
+ } catch (e) {
+ return res.status(400).json({ error: `webhook signature verification failed: ${e.message}` });
+ }
+ if (event.type === 'checkout.session.completed') {
+ const s = event.data.object || {};
+ // same row shape as POST /api/revenue/record so the P&L folds it in as-is
+ const row = { ts: new Date().toISOString(), engine: 'sell_product', amount: round((s.amount_total || 0) / 100, 2), source: `stripe:${s.id}` };
+ try { fs.appendFileSync(REVENUE_LEDGER, JSON.stringify(row) + '\n'); }
+ catch (e) { return res.status(500).json({ error: 'ledger write failed' }); }
+ rebuildFresh().catch(() => {});
+ }
+ res.json({ received: true });
+});
+
app.use(express.json({ limit: '256kb' }));
// Basic-Auth gate on everything EXCEPT /api/healthz (smoke tests + uptime probes)
@@ -767,6 +895,40 @@ app.post('/api/revenue/record', async (req, res) => {
res.json({ ok: true, recorded: row, pnl: SNAP.pnl && SNAP.pnl.today });
});
+// Stripe checkout state for the landing page ("test keys not yet installed" note)
+app.get('/api/stripe/status', (req, res) => res.json({ ...stripeStatus, tiers: Object.keys(TIERS) }));
+
+// Create a Checkout Session for a paid tier (DTD 2026-07-01: Option B —
+// server-created sessions). TEST mode only; 503 until sk_test_ keys land.
+// Subscriptions priced inline so nothing has to pre-exist in the dashboard.
+app.post('/api/checkout', async (req, res) => {
+ if (!stripe) return res.status(503).json({ error: 'stripe not configured', note: stripeStatus.note });
+ const tierKey = ((req.body || {}).tier || '').toString();
+ const tier = TIERS[tierKey];
+ if (!tier) return res.status(400).json({ error: `tier must be one of ${Object.keys(TIERS).join(', ')}` });
+ const base = `${req.protocol}://${req.get('host')}`;
+ try {
+ const session = await stripe.checkout.sessions.create({
+ mode: 'subscription',
+ line_items: [{
+ quantity: 1,
+ price_data: {
+ currency: 'usd',
+ unit_amount: tier.usd * 100,
+ recurring: { interval: 'month' },
+ product_data: { name: tier.label },
+ },
+ }],
+ metadata: { engine: 'sell_product', tier: tierKey },
+ success_url: `${base}/landing?checkout=success`,
+ cancel_url: `${base}/landing?checkout=cancel`,
+ });
+ res.json({ ok: true, url: session.url, id: session.id, mode: 'test' });
+ } catch (e) {
+ res.status(502).json({ error: `stripe error: ${e.message}` });
+ }
+});
+
// === maxit-09: AI chat over the snapshot, LOCAL OLLAMA ($0, cap-relieving) ===
// Natural-language Q&A grounded in the current snapshot. Uses LOCAL Ollama so it
// is $0/unmetered and does NOT consume the Anthropic Max session cap. Endpoints
← c6143c7 queue: task 31 done — dropped-links + norma-videos research
·
back to AbramsEgo
·
feat(sell): stripe TEST checkout + webhook (dtd: B — Checkou 4dc12bf →