A2P campaign verification
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful

This commit is contained in:
Josh 2025-09-14 09:33:44 +00:00
parent 4e8127b5c8
commit 6e673ed514
10 changed files with 438 additions and 80 deletions

View File

@ -1 +1 @@
b5aad6117f63426726be6ae9a07e5aaa938f14ff-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b 33fb91d4b60b7f14d236f83e44c9db42fa1d440f-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -811,14 +811,21 @@ app.post('/api/auth/verify/email/confirm', requireAuth, verifyConfirmLimiter, as
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => { app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => {
try { try {
const { phone_e164 } = req.body || {}; const { phone_e164, consent } = req.body || {};
if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) { if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) {
return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' }); return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' });
} }
// persist/overwrite phone on file if (!consent) return res.status(400).json({ error: 'consent_required' });
await (pool.raw || pool).query('UPDATE user_profile SET phone_e164=? WHERE id=?', [phone_e164, req.userId]); /// persist phone + record explicit consent using existing flag
await (pool.raw || pool).query(
'UPDATE user_profile SET phone_e164=?, sms_opt_in=1 WHERE id=?',
[phone_e164, req.userId]
);
const code = String(Math.floor(100000 + Math.random() * 900000)); const code = String(Math.floor(100000 + Math.random() * 900000));
await sendSMS({ to: phone_e164, body: `AptivaAI security code: ${code}. Expires in 10 minutes.` }); await sendSMS({
to: phone_e164,
body: `AptivaAI code: ${code}. Expires in 10 minutes. Reply STOP to cancel, HELP for help.`
});
// store short-lived challenge in HttpOnly cookie (10 min) // store short-lived challenge in HttpOnly cookie (10 min)
const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' }); const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' });
@ -852,7 +859,11 @@ app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, as
res.clearCookie('aptiva_phone_vc', sessionCookieOptions()); res.clearCookie('aptiva_phone_vc', sessionCookieOptions());
return res.status(200).json({ ok: !!r?.affectedRows }); return res.status(200).json({ ok: !!r?.affectedRows });
} catch (e) { } catch (e) {
console.error('[verify/phone/confirm]', e?.message || e); if (String(e?.code) === '21610') { // Twilio: user replied STOP
try { await (pool.raw || pool).query('UPDATE user_profile SET sms_opt_in=0 WHERE id=?', [req.userId]); } catch {}
return res.status(409).json({ error: 'user_opted_out' });
}
console.error('[verify/phone/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to confirm phone' }); return res.status(500).json({ error: 'Failed to confirm phone' });
} }
}); });

View File

@ -208,6 +208,9 @@ const EXEMPT_PATHS = [
// server3 // server3
/^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data) /^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data)
/^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw) /^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw)
// Twilio webhooks (form-encoded)
/^\/api\/auth\/sms\/inbound$/,
/^\/api\/auth\/sms\/status$/
// add others if truly needed // add others if truly needed
]; ];
@ -681,6 +684,30 @@ app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
// --- Twilio webhooks ---
const twilioForm = express.urlencoded({ extended: false });
app.post('/api/auth/sms/inbound', twilioForm, async (req, res) => {
const body = String(req.body?.Body || '').trim().toUpperCase();
if (body === 'HELP') {
const twiml = `<?xml version="1.0" encoding="UTF-8"?><Response><Message>AptivaAI: Help with SMS. Email admin@aptivaai.com. Msg&Data rates may apply. Reply STOP to cancel.</Message></Response>`;
return res.type('text/xml').send(twiml);
}
return res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?><Response/>`);
});
app.post('/api/auth/sms/status', twilioForm, async (req, res) => {
try {
if (String(req.body?.ErrorCode || '') === '21610' && req.body?.To) {
await (pool.raw || pool).query('UPDATE user_profile SET sms_opt_in=0 WHERE phone_e164=?', [req.body.To]);
}
} catch (e) {
console.error('[sms/status]', e?.message || e);
}
res.sendStatus(204);
});
//*PremiumOnboarding draft //*PremiumOnboarding draft
// GET current user's draft // GET current user's draft
app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {

View File

@ -1,10 +1,30 @@
events {} worker_rlimit_nofile 131072;
events { worker_connections 16384;
}
http { http {
upstream backend5000 { server server1:5000; } keepalive_requests 10000;
upstream backend5001 { server server2:5001; } include /etc/nginx/mime.types;
upstream backend5002 { server server3:5002; } default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_req_zone $binary_remote_addr zone=reqperip:10m rate=100r/s;
set_real_ip_from 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# ───────────── upstreams to Docker services ─────────────
upstream backend5000 { server server1:5000; } # auth & free
upstream backend5001 { server server2:5001;
keepalive 1024;} # onet, distance, etc.
upstream backend5002 { server server3:5002; } # premium
upstream gitea_backend { server gitea:3000; } # gitea service (shared network)
upstream woodpecker_backend { server woodpecker-server:8000; }
########################################################################
# 1. HTTP  HTTPS redirect for the main site
########################################################################
server { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
@ -12,78 +32,235 @@ http {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
########################################################################
# 2. Main virtual host (dev1.aptivaai.com) on :443
########################################################################
server { server {
listen 443 ssl; listen 443 ssl;
http2 on;
http2_max_concurrent_streams 2048;
server_name dev1.aptivaai.com; server_name dev1.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers 'ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256';
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1h;
error_log /var/log/nginx/error.log debug; # ==== RUNTIME PROTECTIONS ====
access_log /var/log/nginx/access.log; server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options SAMEORIGIN always;
# ---------- server1 (5000) ---------- client_max_body_size 10m;
location /api/register { proxy_pass http://server1:5000/api/register; } large_client_header_buffers 4 8k;
location /api/check-username { proxy_pass http://server1:5000/api/check-username; }
location /api/signin { proxy_pass http://server1:5000/api/signin; }
location /api/login { proxy_pass http://server1:5000/api/login; }
location /api/user-profile { proxy_pass http://server1:5000/api/user-profile; }
location /api/areas { proxy_pass http://server1:5000/api/areas; }
location /api/activate-premium{ proxy_pass http://server1:5000/api/activate-premium; }
# ---------- server2 (5001) ---------- client_header_timeout 30s;
location /api/onet/ { proxy_pass http://server2:5001; } client_body_timeout 30s;
location /api/onet/career-description/ { proxy_pass http://server2:5001; } send_timeout 35s;
location /api/job-zones { proxy_pass http://server2:5001/api/job-zones; } keepalive_timeout 65s;
location /api/salary { proxy_pass http://server2:5001/api/salary; }
location /api/cip/ { proxy_pass http://server2:5001/api/cip/; }
location /api/tuition/ { proxy_pass http://server2:5001/api/tuition/; }
location /api/projections/ { proxy_pass http://server2:5001/api/projections/; }
location /api/skills/ { proxy_pass http://server2:5001/api/skills/; }
location = /api/ai-risk { proxy_pass http://server2:5001/api/ai-risk; }
location /api/ai-risk/ { proxy_pass http://server2:5001/api/ai-risk/; }
location /api/chat/ { proxy_set_header X-Request-ID $request_id;
proxy_pass http://server2:5001; add_header X-Request-ID $request_id always;
proxy_http_version 1.1; proxy_request_buffering off;
proxy_buffering off; proxy_max_temp_file_size 0;
} proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
location ^~ /api/maps/distance { proxy_pass http://server2:5001; } if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; }
location /api/schools { proxy_pass http://server2:5001/api/schools; }
# ---------- server3 (5002) ---------- if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; }
location ^~ /api/premium/ { proxy_pass http://server3:5002; }
location /api/public/ { proxy_pass http://server3:5002/api/public/; }
# ---------- static React build ---------- location ~ /\.(?!well-known/) { deny all; }
location ~* \.(?:env|ini|log|sql|sqlite|db|db3|bak|old|orig|swp)$ { deny all; }
# ───── React static assets ─────
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location / { try_files $uri /index.html; } # ======= REVIEW-ONLY SURFACE =======
# 1) Send root to the public A2P page
location = / { return 302 /a2p/; }
# 2) Allowlist public docs
location ^~ /a2p/ { try_files $uri $uri/ =404; } # /public/a2p/index.html
location = /sms { return 302 /sms/; }
location ^~ /sms/ { try_files $uri $uri/ =404; } # /public/sms/index.html
location ^~ /legal/ { try_files $uri $uri/ =404; } # /public/legal/.../index.html
# 3) Keep health + SMS webhooks working
location = /healthz { return 200 'ok'; add_header Content-Type text/plain; }
# (Your existing /api/auth/ proxy already covers /api/auth/sms/*)
# 4) TEMP: block SPA fallback so deep links dont expose the app
location / {
try_files $uri $uri/ =404;
}
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ { location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ {
expires 6M; expires 6M;
access_log off; access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
} }
# ───── API reverseproxy rules ─────
location ^~ /api/onet/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/chat/ {
limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5001;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Connection "";
}
location ^~ /api/job-zones {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/salary {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/cip/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/projections/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/tuition/ { proxy_pass http://backend5001; }
location ^~ /api/skills/ { proxy_pass http://backend5001; }
location ^~ /api/maps/distance { proxy_pass http://backend5001; }
location ^~ /api/schools { proxy_pass http://backend5001; }
location ^~ /api/support {
limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5001;
}
location ^~ /api/data/ { proxy_pass http://backend5001; }
location ^~ /api/careers/ { proxy_pass http://backend5001; }
location ^~ /api/programs/ { proxy_pass http://backend5001; }
location ^~ /api/premium/ {
limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5002;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location ^~ /api/premium/stripe/webhook {
proxy_pass http://backend5002;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location ^~ /api/public/ { proxy_pass http://backend5002; }
location ^~ /api/ai-risk { proxy_pass http://backend5002; }
location = /api/signin { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
location = /api/register { limit_conn perip 3;
limit_req zone=reqperip burst=5 nodelay;
proxy_pass http://backend5000; }
location ^~ /api/auth/ { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
location = /api/user-profile { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
# General API (anything not matched above) rate-limited
location ^~ /api/ { proxy_pass http://backend5000; }
# shared proxy headers
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
error_page 502 503 504 /50x.html; error_page 502 503 504 /50x.html;
location = /50x.html { root /usr/share/nginx/html; } location = /50x.html { root /usr/share/nginx/html; }
} }
}
http {
upstream backend5000 { server server1:5000; }
upstream backend5001 { server server2:5001; }
upstream backend5002 { server server3:5002; }
########################################################################
# 3. Gitea virtual host (HTTPS) gitea.dev1.aptivaai.com
########################################################################
server {
listen 443 ssl;
http2 on;
server_name gitea.dev1.aptivaai.com;
client_max_body_size 1024m;
ssl_certificate /etc/letsencrypt/live/gitea.dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitea.dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://gitea_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
}
########################################################################
# 4. Gitea HTTP  HTTPS redirect
########################################################################
server { server {
listen 80; listen 80;
location /api1/ { proxy_pass http://backend5000/; } server_name gitea.dev1.aptivaai.com;
location /api2/ { proxy_pass http://backend5001/; } return 301 https://$host$request_uri;
location /api3/ { proxy_pass http://backend5002/; } }
########################################################################
# 5. Woodpecker CI HTTPS ci.dev1.aptivaai.com
########################################################################
server {
listen 443 ssl;
http2 on;
server_name ci.dev1.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/ci.dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ci.dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_pass http://woodpecker_backend;
} }
} }
########################################################################
# 6. Woodpecker HTTP  HTTPS redirect
########################################################################
server {
listen 80;
server_name ci.dev1.aptivaai.com;
return 301 https://$host$request_uri;
}}

102
public/a2p/index.html Normal file
View File

@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="robots" content="noindex" />
<title>AptivaAI A2P 10DLC Campaign Verification</title>
<style>
:root { --fg:#111; --muted:#555; --link:#1d4ed8; }
body { font:16px/1.6 system-ui,Segoe UI,Roboto,Arial,sans-serif; color:var(--fg);
max-width:960px; margin:40px auto; padding:0 16px; }
h1 { font-size:28px; margin:0 0 8px; }
h2 { font-size:20px; margin:28px 0 8px; }
a { color:var(--link); text-decoration:underline; }
.muted { color:var(--muted); }
.grid { display:grid; gap:14px; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); }
figure { margin:0; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden; background:#fff; }
figure img { width:100%; display:block; }
figcaption { font-size:12px; padding:8px 10px; color:var(--muted); }
.card { border:1px solid #e5e7eb; border-radius:12px; background:#fff; padding:14px 16px; }
code { font-family:ui-monospace,Menlo,Consolas,monospace; }
ul { margin:8px 0 0 20px; }
</style>
</head>
<body>
<h1>AptivaAI A2P 10DLC Campaign Verification</h1>
<div class="muted">Last updated: Sep 13, 2025</div>
<h2>Program &amp; Use Case</h2>
<div class="card">
<p><strong>Program name:</strong> AptivaAI Reminders</p>
<p><strong>Use case:</strong> Two-Factor Authentication (2FA) / account security only. No marketing or promotional content.</p>
<ul>
<li><strong>Frequency:</strong> varies by user activity</li>
<li><strong>Fees:</strong> Message &amp; data rates may apply</li>
<li><strong>Opt-out:</strong> Reply <strong>STOP</strong> to cancel; <strong>HELP</strong> for help</li>
<li><strong>Contact:</strong> <a href="mailto:support@aptivaai.com">support@aptivaai.com</a></li>
</ul>
</div>
<h2>Call to Action (CTA) & Consent</h2>
<p>Users opt in <em>inside their account</em> by entering a mobile number, checking a non-prechecked consent box linking to our
<a href="/sms" target="_blank" rel="noreferrer">SMS Terms</a>, <a href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>, and
<a href="/legal/terms" target="_blank" rel="noreferrer">Terms</a>, then requesting an SMS code. We verify number ownership with a one-time passcode before enabling SMS.</p>
<div class="grid">
<figure>
<img src="/a2p/assets/verify-screen.png" alt="In-app Verify screen showing consent checkbox, phone field, and Send code button">
<figcaption>In-app CTA and consent (verify screen)</figcaption>
</figure>
<figure>
<img src="/a2p/assets/sms-terms.png" alt="Public SMS Terms page">
<figcaption>Public SMS Terms: fees, frequency, STOP/HELP, contact, links</figcaption>
</figure>
</div>
<h2>Message Flow (2FA)</h2>
<ol>
<li>User toggles SMS and requests a code (after checking consent).</li>
<li>We send a one-time passcode; user enters it to verify the number.</li>
<li>User may reply <strong>STOP</strong> any time to opt out; <strong>START</strong> re-subscribes; <strong>HELP</strong> returns support info.</li>
</ol>
<h2>Sample Messages</h2>
<div class="card">
<p>All samples include brand and opt-out/help language; no shortened links or phone numbers in 2FA content.</p>
<ul>
<li><code>AptivaAI code: 123456. Expires in 10 minutes. Reply STOP to cancel, HELP for help.</code></li>
<li><code>AptivaAI sign-in code: 654321. If you didnt request this, change your password.</code></li>
<li><code>AptivaAI password reset code: 112233. Expires in 10 minutes. Reply STOP to cancel.</code></li>
</ul>
</div>
<h2>STOP/HELP Handling</h2>
<ul>
<li><strong>STOP:</strong> Delivery is blocked and we mark the user opted-out. Users can re-enable via <strong>START</strong> or account settings.</li>
<li><strong>HELP:</strong> We respond with: <em>“AptivaAI: For help email support@aptivaai.com. Msg&amp;Data rates may apply. Reply STOP to cancel.”</em></li>
</ul>
<h2>Public Policies</h2>
<ul>
<li><a href="/sms" target="_blank" rel="noreferrer">SMS Terms</a></li>
<li><a href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a></li>
<li><a href="/legal/terms" target="_blank" rel="noreferrer">Terms of Service</a></li>
</ul>
<h2>Screenshots for Vetting</h2>
<p>High-resolution screenshots are hosted here for reviewers:</p>
<div class="grid">
<figure>
<img src="/a2p/assets/opt-in-checkbox.png" alt="Consent checkbox and copy" />
<figcaption>Consent checkbox (non-prechecked) linking to policies</figcaption>
</figure>
<figure>
<img src="/a2p/assets/otp-sms.png" alt="Example 2FA SMS on device" />
<figcaption>Example OTP SMS</figcaption>
</figure>
</div>
<p class="muted">Carriers are not liable for delayed or undelivered messages.</p>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Privacy AptivaAI</title>
<style>
body{font:16px/1.6 system-ui,Segoe UI,Roboto,Arial,sans-serif;max-width:820px;margin:40px auto;padding:0 16px;color:#111}
h1{font-size:28px;margin:0 0 16px}
a{color:#1d4ed8;text-decoration:underline}
small{color:#555}
</style>
<h1>Privacy Policy</h1>
<p>We collect account information (name, email), an optional phone number to enable SMS, and usage data. We use this information to provide and secure the service, including sending SMS you enable (e.g., verification codes). We share data with processors under contracts that limit use to our instructions. You can opt out of SMS by replying <strong>STOP</strong>.</p>
<p>Contact: <a href="mailto:support@aptivaai.com">support@aptivaai.com</a></p>
<p><small>Last updated: Sep 13, 2025</small></p>

View File

@ -0,0 +1,13 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Terms AptivaAI</title>
<style>
body{font:16px/1.6 system-ui,Segoe UI,Roboto,Arial,sans-serif;max-width:820px;margin:40px auto;padding:0 16px;color:#111}
h1{font-size:28px;margin:0 0 16px}
a{color:#1d4ed8;text-decoration:underline}
small{color:#555}
</style>
<h1>Terms of Service</h1>
<p>The service is provided “as is.” Liability is limited to fees paid in the prior 12 months. If you enable SMS, you consent to receive 2FA/security texts as described in the <a href="/sms">SMS Terms</a>. Message &amp; data rates may apply. Reply <strong>STOP</strong> to cancel, <strong>HELP</strong> for help.</p>
<p><small>Last updated: Sep 13, 2025</small></p>

View File

@ -1,15 +0,0 @@
<h1>SMS Consent & Opt-In Terms</h1>
<p>
When you check the box “Send me SMS task-reminder texts (standard rates apply)” during Premium
onboarding, you agree to receive recurring, automated text messages from AptivaAI at the phone number
you provided. Message frequency depends on your task schedule. Message and data rates may apply.
</p>
<p>
Reply <strong>STOP</strong> at any time to cancel, or <strong>HELP</strong> for help. You can also toggle SMS
reminders off inside your account settings. For more details, see our
<a href="/privacy">Privacy Policy</a> and <a href="/terms">Terms of Service</a>.
</p>
<p>Questions? Email support@aptiva.com.</p>

20
public/sms/index.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AptivaAI SMS Terms</title>
<style>
body{font:16px/1.6 system-ui,Segoe UI,Roboto,Arial,sans-serif;max-width:820px;margin:40px auto;padding:0 16px;color:#111}
h1{font-size:28px;margin:0 0 16px}
a{color:#1d4ed8;text-decoration:underline}
small{color:#555}
</style>
<h1>SMS Consent & Opt-In Terms</h1>
<p>When you check the box “Send me SMS texts” in your account, you agree to receive account security and verification messages from <strong>AptivaAI</strong> at the number you provide. Message frequency varies. Message &amp; data rates may apply.</p>
<ul>
<li><strong>Opt-in:</strong> Enable SMS in your account and verify your number.</li>
<li><strong>Opt-out:</strong> Reply <strong>STOP</strong> to cancel. Reply <strong>HELP</strong> for help.</li>
<li><strong>Support:</strong> <a href="mailto:support@aptivaai.com">support@aptivaai.com</a></li>
<li><strong>Carriers:</strong> Not liable for delayed or undelivered messages.</li>
</ul>
<p>See our <a href="/legal/privacy">Privacy Policy</a> and <a href="/legal/terms">Terms of Service</a>.</p>
<p><small>Last updated: Sep 13, 2025</small></p>

View File

@ -45,10 +45,15 @@ export default function Verify() {
if (sendingSms) return; if (sendingSms) return;
setSendingSms(true); setSendingSms(true);
try { try {
await api.post('/api/auth/verify/phone/send', { phone_e164: phone }); await api.post('/api/auth/verify/phone/send', { phone_e164: phone, consent: smsConsent });
setMsg('SMS code sent.'); setMsg('SMS code sent.');
} catch { } catch (e) {
setMsg('Could not send SMS code.'); const err = e?.response?.data?.error || '';
setMsg(err === 'consent_required'
? 'Please check the consent box first.'
: err === 'user_opted_out'
? 'This number opted out by replying STOP. Text START to re-enable, then try again.'
: 'Could not send SMS code.');
} finally { } finally {
// re-enable after 30s; avoids hammering the endpoint // re-enable after 30s; avoids hammering the endpoint
setTimeout(() => setSendingSms(false), 30000); setTimeout(() => setSendingSms(false), 30000);
@ -121,9 +126,13 @@ export default function Verify() {
onChange={e => setSmsConsent(e.target.checked)} onChange={e => setSmsConsent(e.target.checked)}
/> />
<label htmlFor="sms-consent" className="text-xs text-gray-700 leading-5"> <label htmlFor="sms-consent" className="text-xs text-gray-700 leading-5">
By requesting a code, you agree to receive one-time security texts from AptivaAI to verify your account. By requesting a code, you agree to the{' '}
Reply STOP to opt out. Msg & data rates may apply. <a className="underline" href="/sms" target="_blank" rel="noreferrer">SMS Terms</a>,{' '}
<a className="underline" href="/legal/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>,{' '}
and <a className="underline" href="/legal/terms" target="_blank" rel="noreferrer">Terms</a>.{' '}
Reply STOP to opt out. Msg &amp; data rates may apply.
</label> </label>
</div> </div>