← back to Costa Rica
Costa Rica server: remove the contacts import / list / invite endpoints (TK-10387, DTD verdict C)
89fa33c18b7bf15e8bc4b3d8b9f60eacb8e95a7c · 2026-09-03 20:17:25 -0700 · Steve
Deleting only the iOS client screen was NOT sufficient. POST /contacts/import
stayed live and authenticated, still willing to accept and persist 5000 rows of
{name, phone, email} for third parties from any client, and GET /contacts still
read that PII back. Removing the server path is what makes the app's 'no contacts
are collected' claim structurally true rather than merely unused - this was the
contrarian reviewer's surviving objection to the panel verdict.
Prod verified EMPTY before removal (select count(*) from contacts -> 0), so no
third-party data was ever actually collected and there is nothing to purge.
Removes POST /contacts/import, GET /contacts, POST /contacts/:id/invite, replaced
by a tombstone comment recording why, and how to rebuild invites safely if ever
wanted (share-sheet/deep-link, or real PSI - salted hashing is insufficient because
E.164 is a ~10^10 space).
normalizePhone is retained and still used by the listing-contact path; its tests
are unaffected. Local only - not deployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfGYEoLBywwJD1nfrHe1on
Files touched
Diff
commit 89fa33c18b7bf15e8bc4b3d8b9f60eacb8e95a7c
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Sep 3 20:17:25 2026 -0700
Costa Rica server: remove the contacts import / list / invite endpoints (TK-10387, DTD verdict C)
Deleting only the iOS client screen was NOT sufficient. POST /contacts/import
stayed live and authenticated, still willing to accept and persist 5000 rows of
{name, phone, email} for third parties from any client, and GET /contacts still
read that PII back. Removing the server path is what makes the app's 'no contacts
are collected' claim structurally true rather than merely unused - this was the
contrarian reviewer's surviving objection to the panel verdict.
Prod verified EMPTY before removal (select count(*) from contacts -> 0), so no
third-party data was ever actually collected and there is nothing to purge.
Removes POST /contacts/import, GET /contacts, POST /contacts/:id/invite, replaced
by a tombstone comment recording why, and how to rebuild invites safely if ever
wanted (share-sheet/deep-link, or real PSI - salted hashing is insufficient because
E.164 is a ~10^10 space).
normalizePhone is retained and still used by the listing-contact path; its tests
are unaffected. Local only - not deployed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfGYEoLBywwJD1nfrHe1on
---
routes/app.js | 70 ++++++++++++++++++++---------------------------------------
1 file changed, 23 insertions(+), 47 deletions(-)
diff --git a/routes/app.js b/routes/app.js
index ed44654..73030a1 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -304,53 +304,29 @@ async function confirmBooking(bookingId) {
} catch (e) { console.warn('[confirm] wa notify failed', e.message); }
}
-// ---------------------------------------------------------------- contacts (import + invite)
-// Bulk import the user's address book. Body: { contacts: [{name, phone, email}] }.
-// Normalizes, upserts (dedup per owner), and matches against existing users.
-router.post('/contacts/import', authRequired, async (req, res) => {
- const list = Array.isArray(req.body?.contacts) ? req.body.contacts.slice(0, 5000) : null;
- if (!list) return bad(res, 400, 'contacts array required');
- let imported = 0, matched = 0;
- for (const c of list) {
- const phone = normalizePhone(c.phone);
- if (!phone) continue;
- const { rows: [mu] } = await pool.query(`SELECT id FROM app_users WHERE phone_e164=$1 AND id<>$2`, [phone, req.user.sub]);
- const r = await pool.query(
- `INSERT INTO contacts (owner_id, display_name, phone_e164, email, matched_user_id)
- VALUES ($1,$2,$3,$4,$5)
- ON CONFLICT (owner_id, phone_e164)
- DO UPDATE SET display_name=COALESCE(EXCLUDED.display_name, contacts.display_name),
- matched_user_id=EXCLUDED.matched_user_id
- RETURNING (xmax=0) AS inserted`,
- [req.user.sub, c.name || null, phone, c.email || null, mu?.id || null]);
- if (r.rows[0].inserted) imported++;
- if (mu) matched++;
- }
- ok(res, { imported, matched, received: list.length });
-});
-
-router.get('/contacts', authRequired, async (req, res) => {
- const { rows } = await pool.query(
- `SELECT c.id, c.display_name, c.phone_e164, c.email, c.invited_at,
- (c.matched_user_id IS NOT NULL) AS on_app
- FROM contacts c WHERE c.owner_id=$1
- ORDER BY on_app DESC, c.display_name NULLS LAST LIMIT 2000`, [req.user.sub]);
- ok(res, { count: rows.length, contacts: rows });
-});
-
-// Generate a WhatsApp invite deep link for a contact (and record the invite).
-router.post('/contacts/:id/invite', authRequired, async (req, res) => {
- const { rows: [c] } = await pool.query(
- `SELECT id, owner_id, display_name, phone_e164, email, matched_user_id, invited_at
- FROM contacts WHERE id=$1 AND owner_id=$2`, [req.params.id, req.user.sub]);
- if (!c) return bad(res, 404, 'contact not found');
- const { rows: [me] } = await pool.query(`SELECT full_name FROM app_users WHERE id=$1`, [req.user.sub]);
- const msg = `${me?.full_name || 'A friend'} invited you to explore & book stays, tours and services in Costa Rica 🇨🇷 → https://costarica.agentabrams.com`;
- const w6 = (c.phone_e164 || '').replace(/^\+/, '');
- const link = `https://wa.me/${w6}?text=${encodeURIComponent(msg)}`;
- await pool.query(`UPDATE contacts SET invited_at=NOW(), invite_channel='whatsapp' WHERE id=$1`, [c.id]);
- ok(res, { invite_link: link });
-});
+// ---------------------------------------------------------------- contacts (REMOVED)
+// The address-book import / contact-list / WhatsApp-invite endpoints were REMOVED
+// 2026-09-03 (TK-10387, DTD verdict C, unanimous 8/8).
+//
+// Why: the iOS client uploaded the user's ENTIRE address book as cleartext
+// {name, phone, email} and this route PERSISTED it — durable storage of third
+// parties' personal data from people who are not users, were never notified and
+// never consented. That also contradicted the app's own NSContactsUsageDescription,
+// which told the user contacts stay on-device and are not collected.
+//
+// Deleting only the client screen was NOT sufficient: this route stayed live and
+// authenticated, still willing to accept and store 5000 PII rows from any client.
+// So the server path is removed too, which is what makes the 'no contacts are
+// collected' claim structurally true rather than merely unused.
+//
+// Prod was verified EMPTY before removal (select count(*) from contacts -> 0), so
+// no third-party data was ever actually collected.
+//
+// If invite-friends is ever rebuilt, do NOT restore this shape. Either use a
+// share-sheet / deep link the user sends themselves (no upload, no permission), or
+// real private set intersection. Salted hashing is not sufficient: E.164 is a
+// ~10^10 space, so an app-global salt is brute-forceable, while a per-user salt
+// makes cross-user matching impossible by construction.
// ---------------------------------------------------------------- contact + in-app messenger
// Contact card for a listing: the real channels for the big buttons.
← a9a77cb add creds-in-URL fetch guard to gated pages (TK-10984)
·
back to Costa Rica
·
costa-rica: fix date-dependent payouts test (113/113 green) 5cf7dca →