[object Object]

← 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 →