Member profiles are optional: an invitation on the dashboard, never a gate

Manson's objection was that requiring a username and a verified email pulls the
build back toward a centralized database of members. He is right, and the
communication gap is real too, so the answer is to ask well rather than to force.

Nothing about holding a position, getting paid, reading the org, the training or
the tools depends on contact details any more. There is no onboarding gate: a
brand-new member registers, lands on their page and is never stopped by a modal.
The dashboard offers a dismissable card ("Not now" snoozes it for a week) that
leads with the thing members actually want, a note the moment a payout lands in
their wallet, and says outright that everything works the same without it. The
inbox is the one place that asks, because a message cannot be delivered to
someone who left no way to reach them, and even there it is an invitation.

The card sits above the tab strip rather than inside the dashboard pane: the page
opens on the pitch tab, so an invitation parked in the dashboard would never be
seen by the new members it is aimed at.

For leaders, /api/public/reach answers "how many of my org can I reach off the
site", scoped by chain.isInTeam so it leaks nothing upward or sideways. That
makes coverage a leader's own problem to solve by asking, not a rule imposed on
members.

Fixes a real bug found by the rewritten suite: the dismissable flag double-booked
as "single-field edit", so saving a username in the opt-in flow closed the dialog
instead of advancing to the email step. Split into oneShot; the suite now asserts
the advance as a regression.

QA, all green: profiles-unit 28, signin-fallback 7, gate-e2e 33 (rewritten to
assert the opposite of what it used to: no forced modal, dismissable everywhere,
visitors unaffected), join-flow 12 cold / 11 refuse / 12 warm.

qa/reseed.sh carries two hard-won guards: never name a shell variable TMP on
Windows (it inherits the system temp dir and rm -rf wipes it), and never pkill.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 04:44:09 -05:00
parent 0189278aa5
commit 91a6893df9
9 changed files with 386 additions and 225 deletions
+51 -39
View File
@@ -4,7 +4,7 @@ const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const chain = require('./chain');
const messages = require('./messages');
const messages = require('./messages');
const profiles = require('./profiles');
const tweet = require('./tweet');
@@ -17,7 +17,7 @@ const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
const IS_PROD = process.env.NODE_ENV === 'production';
messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD });
messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD });
profiles.init({ dataDir: DATA_DIR, sendEmail: sendEmailRaw });
const suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR });
const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR });
@@ -1444,43 +1444,55 @@ async function handleApi(req,res,pathname){
if(!tok)return json(res,500,{error:'Session error — try again.'});
return json(res,200,{ok:true,linked:true,id:memberId},{'Set-Cookie':messages.sessionCookie(tok)});
}
// ---- member profile: username + verified email, REQUIRED before the member
// area opens. Only a session that PROVED ownership (wallet personal_sign or the
// Telegram Mini App bridge) can read or write one; /my/<id> is public and can not.
if(req.method==='GET'&&pathname==='/api/public/profile'){
const s=messages.authFromCookie(req);
// 200 with signedIn:false, not 401: a shared /my/<id> link is opened by people
// who are not members, and a 401 there prints a console error that reads like a broken page.
if(!s)return json(res,200,{ok:true,signedIn:false});
return json(res,200,Object.assign({ok:true,id:s.id,suggest:profiles.suggest(s.id)},profiles.status(s.id)));
}
if(req.method==='POST'&&pathname==='/api/public/profile/username'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
const b=await bodyJson(req).catch(()=>null);
const r=profiles.setUsername(s.id,b&&b.username);
return json(res,r.error?400:200,r);
}
if(req.method==='POST'&&pathname==='/api/public/profile/email-start'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
const b=await bodyJson(req).catch(()=>null);
const r=profiles.startEmail(s.id,b&&b.email);
if(r.ok&&!IS_PROD&&r.devCode===undefined)r.devCode=profiles.peekCode(s.id); // local testing only; never in production
return json(res,r.error?400:200,r);
}
if(req.method==='POST'&&pathname==='/api/public/profile/email-verify'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
const b=await bodyJson(req).catch(()=>null);
const r=profiles.verifyEmail(s.id,b&&b.code);
if(r.ok)console.log('profile complete for position #'+s.id);
return json(res,r.error?400:200,r);
}
if(req.method==='GET'&&pathname==='/api/admin/profiles'){
if(!requireAdmin(req,res))return;
return json(res,200,{coverage:profiles.coverage(),profiles:profiles.adminList()});
}
// ---- member profile: username + verified email, REQUIRED before the member
// area opens. Only a session that PROVED ownership (wallet personal_sign or the
// Telegram Mini App bridge) can read or write one; /my/<id> is public and can not.
// How many people in MY org can receive a message. Session required, and the
// ids are filtered to the caller's own team, so this leaks nothing upward or sideways.
if(req.method==='GET'&&pathname==='/api/public/reach'){
const s=messages.authFromCookie(req);
if(!s)return json(res,200,{ok:true,signedIn:false});
let ids=[];
try{
const raw=String(new URL(req.url,'http://x').searchParams.get('ids')||'');
ids=raw.split(',').map(Number).filter(n=>Number.isInteger(n)&&n>0).slice(0,3000).filter(n=>n!==s.id&&chain.isInTeam(n,s.id));
}catch(e){}
return json(res,200,Object.assign({ok:true,signedIn:true},profiles.reachFor(ids)));
}
if(req.method==='GET'&&pathname==='/api/public/profile'){
const s=messages.authFromCookie(req);
// 200 with signedIn:false, not 401: a shared /my/<id> link is opened by people
// who are not members, and a 401 there prints a console error that reads like a broken page.
if(!s)return json(res,200,{ok:true,signedIn:false});
return json(res,200,Object.assign({ok:true,id:s.id,suggest:profiles.suggest(s.id)},profiles.status(s.id)));
}
if(req.method==='POST'&&pathname==='/api/public/profile/username'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
const b=await bodyJson(req).catch(()=>null);
const r=profiles.setUsername(s.id,b&&b.username);
return json(res,r.error?400:200,r);
}
if(req.method==='POST'&&pathname==='/api/public/profile/email-start'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
const b=await bodyJson(req).catch(()=>null);
const r=profiles.startEmail(s.id,b&&b.email);
if(r.ok&&!IS_PROD&&r.devCode===undefined)r.devCode=profiles.peekCode(s.id); // local testing only; never in production
return json(res,r.error?400:200,r);
}
if(req.method==='POST'&&pathname==='/api/public/profile/email-verify'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});
const b=await bodyJson(req).catch(()=>null);
const r=profiles.verifyEmail(s.id,b&&b.code);
if(r.ok)console.log('profile complete for position #'+s.id);
return json(res,r.error?400:200,r);
}
if(req.method==='GET'&&pathname==='/api/admin/profiles'){
if(!requireAdmin(req,res))return;
return json(res,200,{coverage:profiles.coverage(),profiles:profiles.adminList()});
}
if(req.method==='POST'&&pathname==='/api/public/msg-send'){
const s=messages.authFromCookie(req);
if(!s)return json(res,401,{error:'Not signed in.'});