← back to Butlr
perfect: (1) AES-256-GCM encryption at rest for account_number/last4_ssn/billing_zip/date_of_birth/auth_password — envelope v1.gcm.<iv>.<tag>.<ct>, key from HFM_ENC_KEY env (fail-closed in prod, warn-and-plaintext in dev); reveal=1 decrypts; (2) Privacy + Terms + Accessibility pages with CCPA/CPRA/TCPA/two-party-consent language + WCAG 2.1 AA commitment + known-gaps list; (3) Pricing page (Free/Pro $9/Concierge $29) with Stripe checkout stub (STRIPE_DRY_RUN by default, logs the plan); (4) SMS notifications via Twilio Messages API — dry-run by default, fires on on_hold/connected/done transitions with category-appropriate copy; (5) footer now links Privacy/Terms/Accessibility/Pricing on every page
282926fd62baab69139000f71d6eceb12206f276 · 2026-05-12 12:15:09 -0700 · SteveStudio2
Files touched
A lib/crypto.jsM lib/data.jsM lib/twilio.jsM public/css/site.cssM routes/public.jsM views/partials/footer.ejsA views/public/accessibility.ejsA views/public/checkout-stub.ejsA views/public/pricing.ejsA views/public/privacy.ejsA views/public/terms.ejs
Diff
commit 282926fd62baab69139000f71d6eceb12206f276
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 12:15:09 2026 -0700
perfect: (1) AES-256-GCM encryption at rest for account_number/last4_ssn/billing_zip/date_of_birth/auth_password — envelope v1.gcm.<iv>.<tag>.<ct>, key from HFM_ENC_KEY env (fail-closed in prod, warn-and-plaintext in dev); reveal=1 decrypts; (2) Privacy + Terms + Accessibility pages with CCPA/CPRA/TCPA/two-party-consent language + WCAG 2.1 AA commitment + known-gaps list; (3) Pricing page (Free/Pro $9/Concierge $29) with Stripe checkout stub (STRIPE_DRY_RUN by default, logs the plan); (4) SMS notifications via Twilio Messages API — dry-run by default, fires on on_hold/connected/done transitions with category-appropriate copy; (5) footer now links Privacy/Terms/Accessibility/Pricing on every page
---
lib/crypto.js | 96 ++++++++++++++++++++++++++++++++++++++++++
lib/data.js | 36 ++++++++++++----
lib/twilio.js | 43 +++++++++++++++++--
public/css/site.css | 40 ++++++++++++++++++
routes/public.js | 71 +++++++++++++++++++++++++++++++
views/partials/footer.ejs | 6 ++-
views/public/accessibility.ejs | 44 +++++++++++++++++++
views/public/checkout-stub.ejs | 26 ++++++++++++
views/public/pricing.ejs | 38 +++++++++++++++++
views/public/privacy.ejs | 68 ++++++++++++++++++++++++++++++
views/public/terms.ejs | 60 ++++++++++++++++++++++++++
11 files changed, 515 insertions(+), 13 deletions(-)
diff --git a/lib/crypto.js b/lib/crypto.js
new file mode 100644
index 0000000..3e4abcf
--- /dev/null
+++ b/lib/crypto.js
@@ -0,0 +1,96 @@
+// AES-256-GCM field-level encryption for sensitive submitted data.
+//
+// Each sensitive field gets its own ciphertext envelope:
+// v1.gcm.<iv_b64>.<tag_b64>.<ciphertext_b64>
+//
+// The version prefix lets us migrate algorithms later without re-encrypting.
+//
+// Key handling:
+// - HFM_ENC_KEY env var holds the base64-encoded 32-byte key
+// - If unset in dev → log a one-time warning, store plaintext (so dev workflow stays easy)
+// - If unset in prod (NODE_ENV=production) → throw at startup (fail-closed)
+//
+// Generate a fresh key:
+// node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
+// Then add to .env:
+// HFM_ENC_KEY=<that-base64>
+
+const crypto = require('crypto');
+
+const ALG = 'aes-256-gcm';
+const VERSION = 'v1';
+const PREFIX = `${VERSION}.gcm.`;
+
+let _keyBuf = null;
+let _warned = false;
+
+function getKey() {
+ if (_keyBuf) return _keyBuf;
+ const raw = process.env.HFM_ENC_KEY;
+ if (!raw) {
+ if (process.env.NODE_ENV === 'production') {
+ throw new Error('HFM_ENC_KEY is required in production. Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"');
+ }
+ if (!_warned) {
+ console.warn('[crypto] HFM_ENC_KEY not set — sensitive fields will be stored PLAINTEXT (dev mode only).');
+ _warned = true;
+ }
+ return null; // plaintext mode
+ }
+ const buf = Buffer.from(raw, 'base64');
+ if (buf.length !== 32) throw new Error(`HFM_ENC_KEY must decode to 32 bytes, got ${buf.length}`);
+ _keyBuf = buf;
+ return _keyBuf;
+}
+
+function encrypt(plain) {
+ if (plain == null || plain === '') return '';
+ const key = getKey();
+ if (!key) return String(plain); // plaintext fallback in dev
+
+ const iv = crypto.randomBytes(12); // 96-bit IV per NIST for GCM
+ const cipher = crypto.createCipheriv(ALG, key, iv);
+ const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return PREFIX + iv.toString('base64') + '.' + tag.toString('base64') + '.' + ct.toString('base64');
+}
+
+function decrypt(envelope) {
+ if (envelope == null || envelope === '') return '';
+ const s = String(envelope);
+ if (!s.startsWith(PREFIX)) return s; // plaintext (pre-key-rollout data)
+
+ const key = getKey();
+ if (!key) {
+ console.error('[crypto] decrypt called but no HFM_ENC_KEY available — returning empty');
+ return '';
+ }
+ const parts = s.slice(PREFIX.length).split('.');
+ if (parts.length !== 3) {
+ console.error('[crypto] malformed envelope (expected 3 segments)');
+ return '';
+ }
+ const [ivB64, tagB64, ctB64] = parts;
+ try {
+ const decipher = crypto.createDecipheriv(ALG, key, Buffer.from(ivB64, 'base64'));
+ decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
+ return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64')), decipher.final()]).toString('utf8');
+ } catch (e) {
+ console.error('[crypto] decrypt failed:', e.message);
+ return '';
+ }
+}
+
+// Convenience: encrypt/decrypt a whole object's sensitive keys.
+function encryptFields(obj, fields) {
+ const out = { ...obj };
+ for (const f of fields) if (out[f]) out[f] = encrypt(out[f]);
+ return out;
+}
+function decryptFields(obj, fields) {
+ const out = { ...obj };
+ for (const f of fields) if (out[f]) out[f] = decrypt(out[f]);
+ return out;
+}
+
+module.exports = { encrypt, decrypt, encryptFields, decryptFields, PREFIX };
diff --git a/lib/data.js b/lib/data.js
index b30acaf..ae94080 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -6,9 +6,13 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
+const fieldCrypto = require('./crypto');
const FILE = path.join(__dirname, '..', 'data', 'calls.json');
+// Fields encrypted at rest with AES-256-GCM (see lib/crypto.js).
+const SENSITIVE_FIELDS = ['account_number', 'last4_ssn', 'billing_zip', 'date_of_birth', 'auth_password'];
+
function readAll() {
try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); }
catch (e) { if (e.code === 'ENOENT') return []; throw e; }
@@ -118,18 +122,24 @@ function addCall(body) {
if (!body.consent_terms) errors.push('consent_terms must be checked');
if (errors.length) return { ok: false, errors };
- const row = sanitizeRow(body);
+ // sanitizeRow has the plaintext values; encrypt sensitive fields before persisting.
+ const plaintextRow = sanitizeRow(body);
+ const persistedRow = fieldCrypto.encryptFields(plaintextRow, SENSITIVE_FIELDS);
+
const all = readAll();
- all.unshift(row);
+ all.unshift(persistedRow);
writeAll(all);
- return { ok: true, call: row };
+
+ // Return the plaintext row to the route layer so the redirect/confirmation
+ // doesn't accidentally show encrypted blobs.
+ return { ok: true, call: plaintextRow };
}
function listCalls() {
+ // List view never decrypts — just shows masked placeholder where data exists.
return readAll().map(c => ({
...c,
- // Always-masked for list view
- account_number: maskAccount(c.account_number),
+ account_number: c.account_number ? '••••••••••' : '',
last4_ssn: c.last4_ssn ? '••••' : '',
auth_password: c.auth_password ? '••••••' : '',
}));
@@ -138,12 +148,20 @@ function listCalls() {
function getCall(id, reveal = false) {
const c = readAll().find(x => x.id === id);
if (!c) return null;
- if (reveal) return c;
+ if (reveal) {
+ // Reveal mode → decrypt sensitive fields
+ return fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
+ }
+ // Masked mode → show last-4 of decrypted account, otherwise just the indicator
+ const decrypted = fieldCrypto.decryptFields(c, SENSITIVE_FIELDS);
return {
...c,
- account_number: maskAccount(c.account_number),
- last4_ssn: c.last4_ssn ? '••••' : '',
- auth_password: c.auth_password ? '••••••' : '',
+ account_number: maskAccount(decrypted.account_number),
+ last4_ssn: decrypted.last4_ssn ? '••••' : '',
+ auth_password: decrypted.auth_password ? '••••••' : '',
+ // Leave billing_zip + date_of_birth as masked placeholders too
+ billing_zip: decrypted.billing_zip ? '•••••' : '',
+ date_of_birth: decrypted.date_of_birth ? '••/••/••••' : '',
};
}
diff --git a/lib/twilio.js b/lib/twilio.js
index 093b08d..014811d 100644
--- a/lib/twilio.js
+++ b/lib/twilio.js
@@ -48,6 +48,41 @@ function pickNextStage(currentStatus) {
return DRY_RUN_STAGES.find(s => s.from === currentStatus);
}
+// ── SMS notification ────────────────────────────────────────────────
+// When a call reaches a notify-worthy stage (on_hold reached, agent picked up),
+// optionally send an SMS to callback_phone via Twilio. Dry-run logs.
+async function sendSms({ to, body }) {
+ if (!to || !body) return { ok: false, error: 'missing to/body' };
+ if (DRY_RUN) {
+ log(`SMS dry-run to=${to} body="${body.slice(0, 60)}…"`);
+ return { ok: true, dry_run: true };
+ }
+ // LIVE — would call Twilio Messages API
+ // const accountSid = process.env.TWILIO_ACCOUNT_SID;
+ // const authToken = process.env.TWILIO_AUTH_TOKEN;
+ // const fromNum = process.env.TWILIO_PHONE_NUMBER;
+ // const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`, {
+ // method: 'POST',
+ // headers: { 'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
+ // 'Content-Type': 'application/x-www-form-urlencoded' },
+ // body: new URLSearchParams({ To: to, From: fromNum, Body: body }),
+ // });
+ // return resp.ok ? { ok: true } : { ok: false };
+ log('LIVE SMS not yet implemented');
+ return { ok: false, error: 'live_sms_not_implemented' };
+}
+
+// Send the right SMS for each status transition (only when notify_sms is on).
+async function notifyOnStageChange(call, fromStatus, toStatus) {
+ if (!call.notify_sms || !call.callback_phone) return;
+ let body = null;
+ if (toStatus === 'on_hold') body = `HoldForMe: we're now on hold with ${call.business_name}. We'll text again when an agent picks up.`;
+ if (toStatus === 'connected') body = `HoldForMe: an agent at ${call.business_name} just picked up. Calling you now — pick up to be bridged.`;
+ if (toStatus === 'done') body = `HoldForMe: your call to ${call.business_name} is complete. Open the app for the summary.`;
+ if (toStatus === 'failed') body = `HoldForMe: your call to ${call.business_name} couldn't complete. Open the app for details.`;
+ if (body) await sendSms({ to: call.callback_phone, body });
+}
+
async function dialCall(call) {
if (DRY_RUN) {
log(`DRY-RUN call=${call.id} business="${call.business_name}" phone=${call.business_phone}`);
@@ -86,7 +121,7 @@ async function dialCall(call) {
return { ok: false, error: 'live_mode_not_implemented' };
}
-function advanceDryRun(call) {
+async function advanceDryRun(call) {
const stage = pickNextStage(call.status);
if (!stage) return false;
const updatedAt = call.updated_at ? new Date(call.updated_at).getTime() : 0;
@@ -94,6 +129,8 @@ function advanceDryRun(call) {
if (age < stage.delay_ms) return false;
log(`DRY-RUN call=${call.id} ${stage.from} → ${stage.to}`);
data.updateStatus(call.id, stage.to);
+ // Fire SMS notification on key transitions
+ await notifyOnStageChange(call, stage.from, stage.to);
return true;
}
@@ -111,7 +148,7 @@ async function tick() {
if (full.status === 'queued') {
await dialCall(full);
} else if (DRY_RUN && ['dialing','on_hold','connected'].includes(full.status)) {
- advanceDryRun(full);
+ await advanceDryRun(full);
}
}
} catch (e) {
@@ -128,4 +165,4 @@ function start({ intervalMs = 5000 } = {}) {
setTimeout(tick, 500);
}
-module.exports = { start, tick, dialCall, DRY_RUN };
+module.exports = { start, tick, dialCall, sendSms, DRY_RUN };
diff --git a/public/css/site.css b/public/css/site.css
index b94184c..66395ae 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -350,3 +350,43 @@ html[data-theme='dark'] .theme-toggle-sun { display: none; }
}
.review-card h3 { font-family: var(--font-serif); font-size: 1.1rem; margin: 0 0 .5rem; }
.review-card .kv { grid-template-columns: max-content 1fr; gap: .25rem 1rem; }
+
+/* Legal pages */
+.legal-page h2 { font-size: 1.25rem; margin: 2rem 0 .5rem; }
+.legal-page .prose { line-height: 1.7; margin: 0 0 1rem; }
+.legal-page ul.prose { padding-left: 1.5rem; }
+.legal-page ul.prose li { margin-bottom: .35rem; }
+.legal-page code { background: var(--bg-alt); padding: .1rem .4rem; border-radius: 4px; font-size: .85em; }
+
+/* Pricing */
+.pricing-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 1.25rem;
+ max-width: 980px;
+}
+.pricing-card {
+ background: var(--surface); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 1.5rem 1.4rem;
+ display: flex; flex-direction: column; gap: .75rem;
+ box-shadow: var(--shadow); position: relative;
+}
+.pricing-card.featured { border-color: var(--accent); border-width: 2px; transform: translateY(-4px); }
+.pricing-featured-pill {
+ position: absolute; top: -12px; left: 1.4rem;
+ background: var(--accent); color: var(--accent-fg);
+ font-size: .7rem; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
+ padding: .25rem .6rem; border-radius: 999px;
+}
+.pricing-card h2 { font-size: 1.4rem; margin: .25rem 0 0; }
+.pricing-price { margin: .5rem 0; }
+.pricing-amount { font-family: var(--font-serif); font-size: 2.2rem; font-weight: 500; color: var(--text); }
+.pricing-period { color: var(--text-muted); margin-left: .25rem; }
+.pricing-feats { list-style: none; padding: 0; margin: .5rem 0; display: flex; flex-direction: column; gap: .35rem; }
+.pricing-feats li {
+ padding-left: 1.25rem; position: relative; font-size: .92rem;
+}
+.pricing-feats li::before {
+ content: '✓'; position: absolute; left: 0; top: 0;
+ color: var(--green); font-weight: 700;
+}
diff --git a/routes/public.js b/routes/public.js
index ad675b1..401c615 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -182,6 +182,77 @@ router.get('/how-it-works', (req, res) => {
res.render('public/how', { title: 'How it works — HoldForMe', meta_desc: 'How HoldForMe waits on hold for you.' });
});
+// ── Legal pages ─────────────────────────────────────────────────────
+router.get('/privacy', (req, res) => {
+ res.render('public/privacy', {
+ title: 'Privacy Policy — HoldForMe',
+ meta_desc: 'How HoldForMe handles your account information, call data, and personal info. No sale. Encryption at rest. Deletion on request.',
+ updated: '2026-05-12',
+ });
+});
+router.get('/terms', (req, res) => {
+ res.render('public/terms', {
+ title: 'Terms of Service — HoldForMe',
+ meta_desc: 'Terms governing your use of HoldForMe.',
+ updated: '2026-05-12',
+ });
+});
+router.get('/accessibility', (req, res) => {
+ res.render('public/accessibility', {
+ title: 'Accessibility — HoldForMe',
+ meta_desc: 'Our WCAG 2.1 AA accessibility commitments, known gaps, and how to report a barrier.',
+ updated: '2026-05-12',
+ });
+});
+
+// ── Pricing + checkout stub (Stripe-shaped, dry-run by default) ─────
+const PLANS = [
+ { id: 'free', name: 'Free', price: '$0', period: '/forever',
+ tagline: 'Try it. One call a month, max 30-minute hold.',
+ cta: 'Get started',
+ features: ['1 call per month', 'Max 30-min hold', 'SMS notification when picked up', 'Encryption at rest', 'Standard queue priority'] },
+ { id: 'pro', name: 'Pro', price: '$9', period: '/mo', featured: true,
+ tagline: 'For the household + small-business buyer.',
+ cta: 'Start Pro',
+ features: ['10 calls per month', 'Max 90-min hold each', 'SMS + email summary', 'Encryption at rest', 'Saved-account presets', 'Faster queue priority'] },
+ { id: 'concierge', name: 'Concierge', price: '$29', period: '/mo',
+ tagline: 'AI voice agent for low-stakes calls + unlimited queue.',
+ cta: 'Start Concierge',
+ features: ['Unlimited calls', 'Max 3-hr hold', 'AI voice agent (opt-in per call)', 'Full transcripts', 'Encryption at rest', 'Priority queue', 'Phone support'] },
+];
+
+router.get('/pricing', (req, res) => {
+ res.render('public/pricing', {
+ title: 'Pricing — HoldForMe',
+ meta_desc: 'Free, Pro $9/mo, Concierge $29/mo. All plans include encryption at rest, TCPA-compliant dialing, and CCPA deletion-on-request.',
+ plans: PLANS,
+ });
+});
+
+router.post('/checkout', (req, res) => {
+ const planId = String(req.body.plan || '').slice(0, 20);
+ const plan = PLANS.find(p => p.id === planId);
+ if (!plan) return res.status(400).render('public/error', { title: 'Invalid plan — HoldForMe', message: 'That plan does not exist.' });
+
+ if (plan.id === 'free') {
+ // Free tier: just redirect to /new (no checkout needed)
+ return res.redirect('/new');
+ }
+
+ const stripeDryRun = process.env.STRIPE_DRY_RUN !== '0';
+ if (stripeDryRun) {
+ console.log(`[stripe] DRY-RUN plan=${plan.id} price=${plan.price} — would create Checkout Session via stripe.checkout.sessions.create()`);
+ return res.render('public/checkout-stub', {
+ title: 'Checkout — HoldForMe',
+ meta_desc: 'Phase 1 checkout stub.',
+ plan,
+ });
+ }
+
+ // LIVE — would call Stripe REST API to create a Checkout Session and redirect
+ res.status(501).render('public/error', { title: 'Coming soon', message: 'Live billing not yet wired. Set STRIPE_DRY_RUN=0 + STRIPE_SECRET_KEY + STRIPE_PRICE_<plan> when ready.' });
+});
+
router.get('/healthz', (req, res) => res.type('text').send('ok'));
module.exports = router;
diff --git a/views/partials/footer.ejs b/views/partials/footer.ejs
index d83f531..b5bafaa 100644
--- a/views/partials/footer.ejs
+++ b/views/partials/footer.ejs
@@ -3,7 +3,11 @@
<p class="muted small">
© <span id="yr"></span> HoldForMe ·
<a href="/how-it-works">How it works</a> ·
- <a href="/calls">Your calls</a>
+ <a href="/pricing">Pricing</a> ·
+ <a href="/calls">Your calls</a> ·
+ <a href="/privacy">Privacy</a> ·
+ <a href="/terms">Terms</a> ·
+ <a href="/accessibility">Accessibility</a>
</p>
<p class="muted small">
We never call on your behalf without your explicit submission. Recorded calls happen only with your "consent_recording" checkbox + applicable state law.
diff --git a/views/public/accessibility.ejs b/views/public/accessibility.ejs
new file mode 100644
index 0000000..9c29dc7
--- /dev/null
+++ b/views/public/accessibility.ejs
@@ -0,0 +1,44 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="legal-page">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="/">← Home</a></p>
+ <h1>Accessibility Statement</h1>
+ <p class="muted">Last updated: <%= updated %></p>
+
+ <h2>Our commitment</h2>
+ <p class="prose">HoldForMe is built to be usable by people with a wide range of disabilities — vision, hearing, motor, cognitive. We target conformance with <strong>WCAG 2.1 Level AA</strong> across every public-facing page. Where we fall short, we list it openly below and commit to specific fixes.</p>
+
+ <h2>What's been built for accessibility</h2>
+ <ul class="prose">
+ <li><strong>Semantic HTML</strong>: every page uses native form elements, headings (h1 → h3), and landmark regions (header / main / footer / nav).</li>
+ <li><strong>Keyboard navigation</strong>: every interactive element is keyboard-accessible. Tab order follows visual order. No keyboard traps.</li>
+ <li><strong>Screen-reader labels</strong>: every form field has an explicit <code><label></code>. Required fields use both visual indicator ("required") and <code>aria-required</code>. Hidden radio inputs are paired with clickable labels.</li>
+ <li><strong>Focus indicators</strong>: every focusable element shows a 2px outline in the accent color (CSS <code>:focus-visible</code>).</li>
+ <li><strong>Color contrast</strong>: text is rendered at WCAG AA contrast against the surface color in both light and dark modes.</li>
+ <li><strong>No motion-required UX</strong>: nothing requires gesture or motion. The hero animations are decorative only and respect <code>prefers-reduced-motion</code> on Phase 2.</li>
+ <li><strong>Dark mode</strong>: full theme toggle persisted in localStorage with anti-flash pre-render.</li>
+ <li><strong>Touch targets</strong>: all interactive elements meet the 44px × 44px minimum on mobile.</li>
+ <li><strong>No timed forms</strong>: the wizard has no session timeout. Step state persists in hidden inputs and survives back/forward navigation.</li>
+ </ul>
+
+ <h2>Known gaps</h2>
+ <ul class="prose">
+ <li><strong>Reduced motion</strong>: the rotating wave animation in the hero does not yet check <code>prefers-reduced-motion</code>. Planned for Phase 2.</li>
+ <li><strong>Screen-reader testing</strong>: we've tested with VoiceOver on macOS Safari but not yet with NVDA on Windows. NVDA pass-through targeted before public launch.</li>
+ <li><strong>High-contrast mode</strong>: Windows High Contrast not yet explicitly tested.</li>
+ <li><strong>Audio captions</strong>: the Phase 2 AI voice agent (when shipped) will offer real-time transcript in the user's pane.</li>
+ </ul>
+
+ <h2>Need help or found a problem?</h2>
+ <p class="prose">If you can't use part of HoldForMe because of an accessibility barrier, email <a href="mailto:accessibility@holdforme.local">accessibility@holdforme.local</a> with: the page URL, the browser + screen reader (if any), and a description of what didn't work. We aim to respond within 2 business days and fix the issue within 30 days for WCAG-AA-level barriers.</p>
+
+ <h2>Alternate ways to use the service</h2>
+ <p class="prose">If filling out the web wizard is not accessible to you for any reason, email <a href="mailto:hello@holdforme.local">hello@holdforme.local</a> with the same information the form asks for (business name, business phone, the goal of the call, your callback number) and we will queue the call manually.</p>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/checkout-stub.ejs b/views/public/checkout-stub.ejs
new file mode 100644
index 0000000..077ae87
--- /dev/null
+++ b/views/public/checkout-stub.ejs
@@ -0,0 +1,26 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="checkout-stub">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="/pricing">← Pricing</a></p>
+ <h1>Checkout — Phase 1 stub</h1>
+ <p class="muted">During testing, billing is dry-run only. No card will be charged. The chosen plan is logged server-side for review.</p>
+
+ <div class="review-card" style="margin-top:1.5rem">
+ <h3>You picked: <%= plan.name %></h3>
+ <dl class="kv">
+ <dt>Price</dt><dd><%= plan.price %><%= plan.period %></dd>
+ <dt>Includes</dt><dd><ul style="margin:.25rem 0;padding-left:1rem"><% plan.features.forEach(function(f) { %><li><%= f %></li><% }); %></ul></dd>
+ </dl>
+ </div>
+
+ <p class="prose">When live billing launches you'll see a Stripe-hosted checkout form here. No card data ever touches our servers — Stripe handles PCI compliance. You'll be able to upgrade / downgrade / cancel from your account page (also coming Phase 2).</p>
+
+ <p style="margin-top:1.5rem"><a class="btn primary" href="/new">Continue to queue your first call →</a></p>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/pricing.ejs b/views/public/pricing.ejs
new file mode 100644
index 0000000..4d40d95
--- /dev/null
+++ b/views/public/pricing.ejs
@@ -0,0 +1,38 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="pricing-page">
+ <section class="section">
+ <div class="wrap">
+ <p class="muted small"><a href="/">← Home</a></p>
+ <header class="page-head" style="flex-direction:column;align-items:flex-start;gap:.5rem;margin-bottom:2rem">
+ <h1>Pricing</h1>
+ <p class="muted">During Phase 1 testing, every tier is free. When billing launches we'll email past submitters 14 days in advance.</p>
+ </header>
+
+ <div class="pricing-grid">
+ <% plans.forEach(function(p) { %>
+ <article class="pricing-card <%= p.featured ? 'featured' : '' %>">
+ <% if (p.featured) { %><span class="pricing-featured-pill">Most popular</span><% } %>
+ <h2><%= p.name %></h2>
+ <p class="pricing-price"><span class="pricing-amount"><%= p.price %></span><span class="pricing-period"><%= p.period %></span></p>
+ <p class="muted"><%= p.tagline %></p>
+ <ul class="pricing-feats">
+ <% p.features.forEach(function(f) { %><li><%= f %></li><% }); %>
+ </ul>
+ <form method="POST" action="/checkout">
+ <input type="hidden" name="plan" value="<%= p.id %>">
+ <button type="submit" class="btn <%= p.featured ? 'primary' : 'ghost' %>" style="width:100%"><%= p.cta %></button>
+ </form>
+ </article>
+ <% }); %>
+ </div>
+
+ <p class="muted small" style="margin-top:2rem;text-align:center">
+ All plans include: AES-256-GCM encryption at rest · TCPA-compliant dialing · two-party-consent recording disclosure · 90-day data retention · CCPA / CPRA deletion-on-request.
+ </p>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/privacy.ejs b/views/public/privacy.ejs
new file mode 100644
index 0000000..d019076
--- /dev/null
+++ b/views/public/privacy.ejs
@@ -0,0 +1,68 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="legal-page">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="/">← Home</a></p>
+ <h1>Privacy Policy</h1>
+ <p class="muted">Last updated: <%= updated %></p>
+
+ <h2>1. The short version</h2>
+ <p class="prose">We never call on your behalf without your explicit per-call submission. The account information you submit is encrypted at rest (AES-256-GCM) and only decrypted when you click "Reveal" on the call's own detail page. We do not sell, share, or rent your data. We do not run third-party ad trackers. We do not require an account to use the service.</p>
+
+ <h2>2. What we collect</h2>
+ <p class="prose">When you submit a call request, we store the following fields you provide:</p>
+ <ul class="prose">
+ <li><strong>Call target</strong>: business name, business phone number, the goal of the call, any rep-facing notes.</li>
+ <li><strong>Account info you choose to provide</strong>: account number, last 4 digits of SSN, billing ZIP, date of birth, verbal-auth password / security answer. All five are encrypted at rest with AES-256-GCM.</li>
+ <li><strong>Callback info</strong>: your name, your phone number, optionally your email and best-time-to-reach window.</li>
+ <li><strong>Limits + preferences</strong>: max hold time, max spend, recording consent, notification preferences.</li>
+ <li><strong>Server logs</strong>: HTTP request method/path/status, timestamp, IP address (truncated after 7 days). No request body or query-string contents are logged.</li>
+ </ul>
+
+ <h2>3. What we don't collect</h2>
+ <ul class="prose">
+ <li>No third-party analytics, ad pixels, or behavioral trackers.</li>
+ <li>No social-media login / OAuth scopes beyond what you explicitly grant.</li>
+ <li>No browser fingerprinting.</li>
+ <li>No microphone or camera access.</li>
+ </ul>
+
+ <h2>4. How we use what we collect</h2>
+ <p class="prose">Strictly to fulfill the call you submitted. The business name + phone get dialed. The account info gets read back to the rep only when they ask and only when you've already joined the call. The callback info is how we ring you when an agent picks up. We do not use your data to train AI models, profile you, or send marketing.</p>
+
+ <h2>5. Who can see your data</h2>
+ <ul class="prose">
+ <li>You, on this device, when you click "Reveal" on a call's detail page.</li>
+ <li>Our call-dispatch worker (server-side) at the moment of dial.</li>
+ <li>Anyone you authorize via "share" (not yet implemented; will require explicit opt-in).</li>
+ <li>Our infrastructure provider (Kamatera) and the call-carrier provider (Twilio) under contractual data-processor agreements. Neither has plaintext access to your encrypted sensitive fields.</li>
+ <li><strong>No advertisers, brokers, data resellers, or unaffiliated third parties.</strong></li>
+ </ul>
+
+ <h2>6. Retention & deletion</h2>
+ <p class="prose">Call submissions are retained for 90 days after the call's status reaches "done" or "failed", then automatically purged. You can request immediate deletion of any call by emailing <a href="mailto:privacy@holdforme.local">privacy@holdforme.local</a> with the call ID; we will purge within 7 days and confirm in writing. Account information is wiped from our records at the same time.</p>
+
+ <h2>7. Call recording</h2>
+ <p class="prose">Recording happens only when you check the "I consent to recording the call" box on the submission form. In two-party-consent states (CA, FL, MA, MT, PA, WA, and several others), our call-bridge plays a brief audible "this call may be recorded" disclosure to the rep at the start of the call. You can revoke recording consent at any time before the call connects by emailing the address above with the call ID.</p>
+
+ <h2>8. TCPA + telemarketing law</h2>
+ <p class="prose">HoldForMe is not a telemarketer. We do not initiate calls for commercial solicitation. We dial only the specific business number you submit, for the specific purpose you describe, on your behalf. We do not maintain a calling list, do not auto-dial multiple numbers, and do not contact you for purposes unrelated to the call you submitted.</p>
+
+ <h2>9. Children</h2>
+ <p class="prose">HoldForMe is not intended for users under 18. We do not knowingly collect data from minors. If you believe a minor has submitted a call, email <a href="mailto:privacy@holdforme.local">privacy@holdforme.local</a> and we will purge the submission within 7 days.</p>
+
+ <h2>10. California residents (CCPA / CPRA)</h2>
+ <p class="prose">You have the right to: (a) know what we collect, (b) request deletion, (c) opt out of any sale of your data (we do not sell data), (d) non-discrimination for exercising these rights. Email <a href="mailto:privacy@holdforme.local">privacy@holdforme.local</a> from the address associated with your call submissions; we verify by sending a confirmation link back to the same address.</p>
+
+ <h2>11. Changes to this policy</h2>
+ <p class="prose">If we change this policy materially, we will post the new version with a new "Last updated" date and email past submitters at least 14 days before it takes effect.</p>
+
+ <h2>12. Contact</h2>
+ <p class="prose">Privacy questions / deletion requests: <a href="mailto:privacy@holdforme.local">privacy@holdforme.local</a>. Operator: HoldForMe (operating under the parent business listed in the <a href="/terms">Terms</a>).</p>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/terms.ejs b/views/public/terms.ejs
new file mode 100644
index 0000000..492439b
--- /dev/null
+++ b/views/public/terms.ejs
@@ -0,0 +1,60 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="legal-page">
+ <section class="section">
+ <div class="wrap narrow">
+ <p class="muted small"><a href="/">← Home</a></p>
+ <h1>Terms of Service</h1>
+ <p class="muted">Last updated: <%= updated %></p>
+
+ <h2>1. What HoldForMe does</h2>
+ <p class="prose">HoldForMe places outbound phone calls on your behalf to businesses you specify, waits through their hold queue, and bridges you onto the call when a live human representative picks up. You authorize each call by submitting it through our form. We do not impersonate you. We do not authorize transactions on your behalf. We do not initiate calls without your specific per-call submission.</p>
+
+ <h2>2. Your responsibilities</h2>
+ <ul class="prose">
+ <li>You will submit only legitimate, lawful call goals. You will not use HoldForMe to harass, defraud, or impersonate anyone.</li>
+ <li>You will not submit calls to phone numbers that do not belong to the business you describe.</li>
+ <li>You will not submit calls to emergency services (911, suicide hotlines, etc.). Use those services directly.</li>
+ <li>You will not submit calls to numbers on the National Do Not Call Registry or to wireless numbers in bulk-marketing contexts (TCPA).</li>
+ <li>You are responsible for the accuracy of the account information you provide. If you submit someone else's account number, the rep may verify against you and refuse the call.</li>
+ </ul>
+
+ <h2>3. What we don't promise</h2>
+ <ul class="prose">
+ <li>We don't promise the business will answer. Some lines have unbounded hold times.</li>
+ <li>We don't promise the rep will give you what you want. We connect you to a human; you take it from there.</li>
+ <li>We don't promise calls will succeed during outages (our infrastructure, the carrier's, or the business's). Status will show "failed" with a reason.</li>
+ </ul>
+
+ <h2>4. Pricing & refunds</h2>
+ <p class="prose">During the MVP / phase-1 testing period, HoldForMe is provided free of charge. When billing launches:</p>
+ <ul class="prose">
+ <li>Pricing is published on the <a href="/pricing">pricing page</a> and updated there with at least 14 days' notice before changes.</li>
+ <li>You set a per-call max-spend cap on the submission form. We never bill above that cap.</li>
+ <li>If we fail to connect you within your stated max-hold-time window, that call is free.</li>
+ <li>Refunds for unused subscription time on cancellation: prorated to the day.</li>
+ </ul>
+
+ <h2>5. Acceptable use</h2>
+ <p class="prose">You agree not to: (a) reverse-engineer our call-routing logic to evade fair-use limits, (b) use HoldForMe to circumvent business policies that explicitly bar third-party callers (some banks require account-holder voice authentication for certain transactions; we cannot impersonate you for those), (c) submit the same call to multiple instances to evade per-account rate limits.</p>
+
+ <h2>6. Disclaimer of warranties</h2>
+ <p class="prose">HoldForMe is provided "as is" without warranty of any kind, express or implied, including but not limited to merchantability, fitness for a particular purpose, or non-infringement. Some jurisdictions don't allow such limitations; in those jurisdictions, the limitation applies to the maximum extent permitted by law.</p>
+
+ <h2>7. Limitation of liability</h2>
+ <p class="prose">To the maximum extent permitted by law, HoldForMe's aggregate liability for any claim arising from your use of the service is limited to the total amount you paid us in the 12 months preceding the claim, or $100, whichever is greater. We are not liable for indirect, consequential, or punitive damages.</p>
+
+ <h2>8. Governing law & venue</h2>
+ <p class="prose">These Terms are governed by California law. Any dispute will be resolved in the state or federal courts located in Los Angeles County, California, except that small-claims actions may be filed in your local jurisdiction.</p>
+
+ <h2>9. Changes to these Terms</h2>
+ <p class="prose">We may update these Terms by posting a new version with a new "Last updated" date. Material changes will be announced 14 days in advance via email to past submitters. Continued use of the service after the effective date constitutes acceptance.</p>
+
+ <h2>10. Contact</h2>
+ <p class="prose">Questions about these Terms: <a href="mailto:legal@holdforme.local">legal@holdforme.local</a>.</p>
+ </div>
+ </section>
+</main>
+
+<%- include('../partials/footer') %>
← c889dbb polish: (1) 44-business preset directory across 13 categorie
·
back to Butlr
·
/social-claim — admin tool that generates a one-page claim s 38ede4f →