diff --git a/profiles.js b/profiles.js index abffbbd..93242dd 100644 --- a/profiles.js +++ b/profiles.js @@ -159,6 +159,15 @@ function positionsFor(email) { const e = String(email || '').trim().toLowerCase(); return Object.values(db.byId).filter(p => p.emailVerified && String(p.email || '') === e).map(p => p.id).sort((a, b) => a - b); } +// Reach for one leader's org: how many of the positions below them have given a +// way to be contacted. This is the number that makes coverage a leader's own +// problem rather than a rule imposed on members. +function reachFor(ids) { + const list = (ids || []).map(Number).filter(Boolean); + const reachable = []; + for (const id of list) { const p = db.byId[norm(id)]; if (p && p.emailVerified && p.email) reachable.push(id); } + return { total: list.length, reachable: reachable.length, ids: reachable.slice(0, 2000) }; +} function coverage() { const all = Object.values(db.byId); return { profiles: all.length, withUsername: all.filter(p => p.username).length, @@ -171,5 +180,5 @@ function adminList() { return Object.values(db.byId).map(pub).sort((a, b) => a.i // local testing only: the server exposes this outside production, never on the live site function peekCode(id) { const st = codes.get(norm(id)); return st ? st.code : null; } -module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, +module.exports = { init, get, pub, status, complete, isComplete, setUsername, byUsername, suggest, peekCode, reachFor, startEmail, verifyEmail, contactFor, setTelegram, positionsFor, coverage, adminList, ensure, USER_RE, EMAIL_RE }; diff --git a/public/my.html b/public/my.html index 2fd125f..efa3e51 100644 --- a/public/my.html +++ b/public/my.html @@ -31,5 +31,5 @@ - + diff --git a/public/my.js b/public/my.js index 0bdff4f..3bff7ee 100644 --- a/public/my.js +++ b/public/my.js @@ -315,6 +315,45 @@ }).catch(()=>{}); loadMsgUI(d); } + // A quiet, dismissable invitation on the member's own dashboard. Snoozed for a + // week when dismissed; never shown on a teammate's page or to a visitor. + async function maybeProfileCard(id){ + try{ + var snoozed=0; try{ snoozed=Number(localStorage.getItem('rmc.profileSnooze')||0); }catch(e){} + if(Date.now()Add an email and we will tell you the moment POL lands in your wallet, and your sponsor can reach you when something needs you. '+ + 'Completely optional, never shown to other members, never sold, and removable any time. Your position, your payouts and everything on this page work exactly the same without it.

'+ + ' '+ + ''; + tabs.parentNode.insertBefore(box,tabs); + document.getElementById('pcGo').addEventListener('click',async function(){ + await window.RMCProfile.prompt({onlyForId:id,reason:'alerts'}); + var p2=await window.RMCProfile.status(); + if(p2&&p2.profile&&p2.profile.complete){ box.remove(); try{ renderAlerts({id:id}); }catch(e){} } + }); + document.getElementById('pcNo').addEventListener('click',function(){ + try{ localStorage.setItem('rmc.profileSnooze',String(Date.now()+7*86400000)); }catch(e){} + box.remove(); + }); + }catch(e){} + } + // every position below this one, from the matrix subtree the dashboard already has + function orgPositionIds(d){ + const out=[]; + (function walk(n,depth){ if(!n||depth>16)return; if(depth>=1&&n.id)out.push(Number(n.id)); walk(n.left,depth+1); walk(n.right,depth+1); })(d&&d.subtree,0); + return out; + } async function loadMsgUI(d){ const el=document.getElementById('dMsg'); let me=null; @@ -327,7 +366,23 @@ let data; try{data=await(await fetch('/api/public/msg-inbox')).json();}catch(e){el.innerHTML='
Could not load messages โ€” refresh to retry.
';return;} const mine=Number(me.id)===Number(d.id); - const banner=mine?'':`
You're signed in as #${me.id} โ€” this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)
`; + let banner=mine?'':`
You're signed in as #${me.id} โ€” this inbox is yours. (You're viewing #${d.id}'s page; the "to" box is pre-filled for them.)
`; + // The one place contact details genuinely matter: a message cannot be delivered + // to someone who has given no way to reach them. Asked here, never forced. + try{ + const pr=await window.RMCProfile.status(); + if(pr&&pr.signedIn!==false&&!(pr.profile&&pr.profile.complete)){ + banner+=`
Messages reach you here only. Add an email and they reach you off the site too, plus a note whenever a payout lands. Optional, private, removable any time.
`; + } + const idsInOrg=orgPositionIds(d); + if(mine&&idsInOrg.length){ + const rr=await (await fetch('/api/public/reach?ids='+idsInOrg.slice(0,2000).join(','))).json(); + if(rr&&rr.total){ + const pct=Math.round((rr.reachable/rr.total)*100); + banner+=`
${rr.reachable} of ${rr.total} in your org can be reached off the site (${pct}%). The rest only see a message if they open this page. Ask your two to add an email โ€” it is the difference between a team you can talk to and one you cannot.
`; + } + } + }catch(e){} const rows=(data.inbox||[]).map(m=>`
${m.org?'๐Ÿ“ฃ':'โœ‰๏ธ'}
From #${m.fromId} ยท ${new Date(m.ts).toLocaleString()}${m.org?' ยท team broadcast':''}${m.read?'':' ยท NEW'}
${esc(m.body)}
`).join('')||'
No messages yet.
'; const sent=(data.sent||[]).slice(0,3).map(m=>`
โ†’ ${m.org?'whole team':'#'+m.toId} ยท ${new Date(m.ts).toLocaleString()}: ${esc(m.body.slice(0,90))}${m.body.length>90?'โ€ฆ':''}
`).join(''); el.innerHTML=banner+rows+ @@ -419,7 +474,6 @@ const sig=await eth.request({method:'personal_sign',params:[hex,account]}); const v=await(await fetch('/api/public/msg-verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({address:account,signature:sig})})).json(); if(!v.ok)throw new Error(v.error||'Verification failed.'); - try{ if(window.RMCProfile)await window.RMCProfile.require({onlyForId:d&&d.id}); }catch(ge){} loadMsgUI(d); }catch(e){if(err)err.textContent=e.message||String(e);} } @@ -500,6 +554,9 @@ // to k+2, so the cost to REACH level L is index L-2. Scintilla (L1) is the // entry level, not an upgrade. const upc=(d.upgradeCosts&&d.upgradeCosts[d.tier===2?2:1])||[]; + // Manson, 2026-09-17: showing all eight rungs can read as a required climb. + // It is not โ€” you stop wherever you like, and NEXT is only ever a suggestion + // based on who is actually below you. const ladder=LEVELS.map((nm,i)=>{ const L=i+1;let cls='nlv'; if(L
Your plan
${badge}
${head}

${body}

${ladder}

See what each level opens up โ†’

`; + el.innerHTML=`
Your plan
${badge}
${head}

${body}

${ladder}

You stop wherever you like โ€” there is no level you have to reach. NEXT is only a suggestion based on who is below you today.

See what each level opens up โ†’

`; } function renderPipeline(d){ const el=document.getElementById('dPipeline'); @@ -781,5 +838,7 @@ if(id)load(id); // Required member profile (username + verified email). No-ops for a visitor who // has not proved they own a position: the API 401s and the gate never shows. - try{ if(window.RMCProfile&&id)window.RMCProfile.require({onlyForId:id}); }catch(e){} + // No automatic modal. Contact details are optional; the dashboard shows a + // dismissable card instead, and the inbox asks only when it actually needs one. + try{ if(window.RMCProfile&&id)maybeProfileCard(id); }catch(e){} })(); diff --git a/public/profile-gate.js b/public/profile-gate.js index ae1fa72..e9a1210 100644 --- a/public/profile-gate.js +++ b/public/profile-gate.js @@ -1,15 +1,17 @@ -// RM Circle: required member profile gate (Marty, 2026-09-16). +// RM Circle: OPTIONAL member profile (Marty + Manson, 2026-09-17). // -// The member area only opens once a position has a username and a VERIFIED email. -// It fires the moment ownership is proved (wallet personal_sign, or the Telegram -// Mini App bridge) and cannot be dismissed, because the whole point is that a -// leader can reach every member. The public /my/ page is untouched: anyone -// can still read the chain data there, and nobody can write a profile from it. +// Contact details are never required to hold a position, get paid, read the org, +// use the training or the tools. Nothing on chain depends on them. They are asked +// for in exactly one place where their absence is the whole problem: a message +// cannot be delivered to someone who has given no way to reach them. Everything +// else is an invitation the member can decline, and declining costs them nothing. // -// Usage: await window.RMCProfile.require(); // resolves once the profile is complete +// Usage: window.RMCProfile.prompt({ reason: 'alerts'|'inbox' }) // dismissable +// window.RMCProfile.edit('username'|'email') // change later +// window.RMCProfile.status() // read-only (function () { 'use strict'; - var back = null, resolveDone = null, state = null, editable = false; + var back = null, resolveDone = null, state = null, oneShot = false, showSteps = true, reason = 'alerts'; var GOLD = '#d4af37', TEAL = '#4ed6cb'; function el(tag, css, html) { @@ -30,12 +32,12 @@ back = el('div', 'position:fixed;inset:0;z-index:2147483100;display:flex;align-items:center;justify-content:center;' + 'padding:20px;background:rgba(3,7,14,.88);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);overflow:auto;'); back.setAttribute('role', 'dialog'); - back.setAttribute('aria-label', 'Finish setting up your member profile'); + back.setAttribute('aria-label', 'Member profile โ€” optional'); var card = el('div', 'position:relative;width:100%;max-width:440px;max-height:94vh;overflow:auto;border-radius:20px;' + 'background:#0a1119;border:1px solid rgba(212,175,55,.55);box-shadow:0 24px 70px rgba(0,0,0,.7);' + 'font-family:system-ui,Segoe UI,Arial,sans-serif;color:#e8eef6;padding:22px 22px 20px;'); card.id = 'pgCard'; - if (editable) { // editing an existing profile can be abandoned; the first-time gate can not + { // every profile dialog can be closed: nothing here is compulsory var x = el('button', 'position:absolute;top:10px;right:12px;z-index:2;width:34px;height:34px;border-radius:50%;border:none;' + 'cursor:pointer;background:rgba(255,255,255,.08);color:#fff;font-size:20px;line-height:1;font-weight:700;', '×'); x.id = 'pgClose'; @@ -53,7 +55,7 @@ var keep = card.querySelector('button[aria-label="Close"]'); card.innerHTML = ''; if (keep) card.appendChild(keep); - if (editable) return card; + if (!showSteps) return card; card.appendChild(el('div', 'text-align:center;letter-spacing:2px;text-transform:uppercase;font-size:11px;font-weight:700;color:' + GOLD + ';margin-bottom:8px;', 'Position #' + esc(state.id) + ' ยท step ' + step + ' of 2')); return card; @@ -81,7 +83,7 @@ function stepUsername() { var card = head(document.getElementById('pgCard'), 1); card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Pick your username')); - card.appendChild(note('This is how your team leader and the people in your line see you, instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters.')); + card.appendChild(note('How your team sees you instead of a bare member number. Letters, numbers or underscores, 3 to 20 characters. Optional, and changeable any time.')); var err = errLine(); card.appendChild(err); var i = input('pgUser', 'e.g. ' + (state.suggest || 'member' + state.id), (state.profile && state.profile.username) || ''); card.appendChild(i); @@ -92,7 +94,7 @@ try { var r = await api('/api/public/profile/username', { username: i.value }); state.profile = r.profile; - if (editable) { close(); return; } + if (oneShot) { close(); return; } next(); } catch (e) { err.textContent = e.message; err.style.display = 'block'; b.disabled = false; b.textContent = 'Save and continue'; @@ -108,8 +110,10 @@ var card = head(document.getElementById('pgCard'), 2); var prefill = (state.profile && state.profile.email) || ''; card.appendChild(el('h2', 'margin:0 0 6px;font-size:21px;line-height:1.25;color:#fff;', 'Confirm your email')); - card.appendChild(note('Two reasons this is required. Your leader can actually reach you, and you get a note the moment a payout lands in your wallet. ' + - 'It is never shown to other members, never sold, and you can change it any time.' + + card.appendChild(note((reason === 'inbox' + ? 'Your inbox needs somewhere to reach you. Add an email and your leader\'s messages reach you even when you are not on the site. ' + : 'Get an email the moment a payout lands in your wallet, and your leader can reach you when it matters. ') + + 'Optional, never shown to other members, never sold, and you can remove it any time.' + (prefill ? ' We already have this one on file for your payout alerts, so just confirm it.' : ''))); var err = errLine(); card.appendChild(err); var i = input('pgEmail', 'you@example.com', prefill, 'email'); @@ -144,7 +148,7 @@ try { var r = await api('/api/public/profile/email-verify', { code: ci.value }); state.profile = r.profile; - if (editable) { close(); return; } + if (oneShot) { close(); return; } done(); } catch (e) { err.textContent = e.message; err.style.display = 'block'; cb.disabled = false; cb.textContent = 'Confirm and finish'; @@ -178,35 +182,38 @@ return done(); } - // Resolves once the profile is complete. Safe to call repeatedly: it returns - // immediately when there is nothing to collect, and never shows for a visitor - // who has not proved they own the position. - // opts.onlyForId: only gate when the page being viewed IS this member's own - // position (Marty, 2026-09-16), so browsing a teammate's dashboard never prompts. - async function require_(opts) { + // An invitation, not a gate: fully dismissable, and declining costs nothing. + // opts.onlyForId - only for the member's own position, never a teammate's page. + // opts.reason - 'alerts' (default) or 'inbox', which picks the copy. + async function prompt_(opts) { try { state = await api('/api/public/profile'); } catch (e) { return null; } - if (!state || state.signedIn === false) return null; // a visitor on a shared link: never gate + if (!state || state.signedIn === false) return null; var only = opts && opts.onlyForId; if (only && Number(only) !== Number(state.id)) return null; if (state.profile && state.profile.complete) return state.profile; - if (back) return null; // already open + if (back) return null; + reason = (opts && opts.reason) || 'alerts'; + oneShot = false; // full two-step opt-in, not a single-field edit + showSteps = true; shell(); - return new Promise(function (res) { resolveDone = res; next(); }); + var p = new Promise(function (res) { resolveDone = res; }); + next(); + var out = await p; oneShot = false; return out; } async function status() { try { return await api('/api/public/profile'); } catch (e) { return null; } } - // Change an existing username or email. Dismissable, unlike the first-time gate. + // Change one field. Opens straight at that step and closes on save. // Resolves with the profile when saved, or null if the member closed it. async function edit(kind) { if (back) return null; try { state = await api('/api/public/profile'); } catch (e) { return null; } if (!state || state.signedIn === false) return null; - editable = true; + oneShot = true; showSteps = false; shell(); var p = new Promise(function (res) { resolveDone = res; }); if (kind === 'email') stepEmail(); else stepUsername(); - var out = await p; editable = false; return out; + var out = await p; oneShot = false; return out; } - window.RMCProfile = { require: require_, status: status, edit: edit }; + window.RMCProfile = { prompt: prompt_, status: status, edit: edit }; })(); diff --git a/qa/README.md b/qa/README.md index 36e1528..0bdb671 100644 --- a/qa/README.md +++ b/qa/README.md @@ -1,25 +1,47 @@ # RM Circle QA -## Member profile gate +## Optional member profile + +Since 2026-09-17 contact details are **optional** (Marty + Manson). Nothing about holding a +position, getting paid, reading the org, the training or the tools may depend on a username or +an email. There is no onboarding gate. The dashboard shows a dismissable invitation, and the +inbox asks only because a message cannot be delivered to someone who left no way to reach them. + +If a change to this area makes any of those suites fail on "NO modal is forced", stop. That +assertion is the product decision, not a test detail. ``` # unit: profiles.js in isolation (seeding, username rules, code flow, persistence) node qa/profiles-unit.mjs -# end to end: the real UI with real inbox sessions -TMP=/d/tmp/rmc-e2e-data && rm -rf $TMP && mkdir -p $TMP -ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json -echo '{}' > $TMP/sponsors.json -echo '{"49":{"email":"seeded49@example.com","ts":"x"}}' > $TMP/member-alerts.json -node -e "const m=require('./messages.js'); m.init({dataDir:process.argv[1],chain:require('./chain.js'),isProd:false}); require('fs').writeFileSync(process.argv[1]+'/tokens.txt', m.mintSession(21)+'\n'+m.mintSession(49));" $TMP -PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node server.js & -LOCAL=http://127.0.0.1:3399 TOKEN=$(sed -n 1p $TMP/tokens.txt) TOKEN2=$(sed -n 2p $TMP/tokens.txt) node qa/gate-e2e.mjs +# sign-in fallback: real secp256k1 signatures, including the abuse cases +node qa/signin-fallback.mjs + +# end to end: the real UI with real inbox sessions. +# reseed.sh builds a throwaway data dir and starts the server on 3399. +# NOT idempotent: the suite completes a profile for 21, so reseed before every run. +bash qa/reseed.sh +D=/d/tmp/rmc-e2e-data +LOCAL=http://127.0.0.1:3399 TOKEN=$(sed -n 1p $D/tokens.txt) TOKEN2=$(sed -n 2p $D/tokens.txt) node qa/gate-e2e.mjs ``` -Covers: the gate fires on the owner's own page only, cannot be dismissed (Escape, backdrop, no close -button), username and email validation, the code flow including a wrong code, completion landing on -the dashboard tab, the profile card and in-place editing (dismissable), the seeded email pre-filling -at step 2, visitors on every ID-keyed shared link seeing no gate and no 401, and phone-width layout. +Covers: no automatic modal on the owner's own page or on a phone, the dismissable invitation and +its 7-day snooze, opting in end to end (including the regression that saving a username must +ADVANCE to the email step rather than close), abandoning the dialog at any point, the profile card +and in-place editing, the seeded email pre-filling at step 2, the inbox's reason-led ask, visitors +on every ID-keyed shared link seeing no prompt and no 401, and phone-width layout. + +Two traps that cost real time, both now guarded in `reseed.sh`: + +* **Never call a shell variable `TMP`, `TEMP` or `TMPDIR`.** Windows already sets them to the + system temp directory, so `${TMP:-default}` silently inherits it and a following `rm -rf "$TMP"` + wipes the machine's temp folder, including the tooling's own scratch files. +* **Never add `pkill`/`taskkill`.** A broad pattern kills the tooling running the script. Stop the + old server through the pid file. + +The event flyer and the upgrade promo are full-screen overlays that legitimately cover `/my` once +per browser/session and swallow clicks. Both E2E suites mark them already-seen via an init script +rather than racing their fade-out. `devCode` is returned by `/api/public/profile/email-start` only when `NODE_ENV !== 'production'`, which is what lets the test read the code. The live container runs with NODE_ENV=production. @@ -33,13 +55,21 @@ left #787 without a profile: the cached index does not yet know the brand-new po ``` node -e "const c=require('crypto'),s=require('./vendor/secp256k1.js'),{keccak256}=require('./vendor/sha3.js');s.utils.hmacSha256Sync=(k,...m)=>{const h=c.createHmac('sha256',Buffer.from(k));m.forEach(x=>h.update(Buffer.from(x)));return Uint8Array.from(h.digest())};const p=c.randomBytes(32),pub=s.getPublicKey(p,false);require('fs').writeFileSync('D:/tmp/rmc-qa-wallet.json',JSON.stringify({priv:p.toString('hex'),addr:'0x'+keccak256(Buffer.from(pub.slice(1))).slice(-40)}))" -TMP=/d/tmp/rmc-jd && rm -rf $TMP && mkdir -p $TMP && echo '[]' > $TMP/sponsors.json -ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $TMP/config.json -TEST_ADDR= TEST_ID=9001 COLD=1 PORT=3399 DATA_DIR=$TMP ADMIN_PASSWORD=localtest node qa/harness-server.js & -LOCAL=http://127.0.0.1:3399 TEST_ADDR= TEST_PRIV= TEST_ID=9001 SCENARIO=sign node qa/join-flow-e2e.mjs -LOCAL=http://127.0.0.1:3399 TEST_ADDR= TEST_PRIV= TEST_ID=9002 SCENARIO=refuse node qa/join-flow-e2e.mjs +J=/d/tmp/rmc-jd && rm -rf $J && mkdir -p $J && echo '[]' > $J/sponsors.json # never name it TMP, see the trap above +ssh root@coolify.saasy.top "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > $J/config.json + +# cold index (the state that broke #787) on 3400, warm index on 3402 +TEST_ADDR= TEST_ID=9001 COLD=1 PORT=3400 DATA_DIR=$J ADMIN_PASSWORD=localtest node qa/harness-server.js & +TEST_ADDR= TEST_ID=9003 COLD=0 PORT=3402 DATA_DIR=$J-w ADMIN_PASSWORD=localtest node qa/harness-server.js & + +LOCAL=http://127.0.0.1:3400 TEST_ADDR= TEST_PRIV= TEST_ID=9001 SCENARIO=sign node qa/join-flow-e2e.mjs +LOCAL=http://127.0.0.1:3400 TEST_ADDR= TEST_PRIV= TEST_ID=9002 SCENARIO=refuse node qa/join-flow-e2e.mjs +LOCAL=http://127.0.0.1:3402 TEST_ADDR= TEST_PRIV= TEST_ID=9003 SCENARIO=sign node qa/join-flow-e2e.mjs ``` +`COLD` defaults to ON: only `COLD=0` gives a warm index, so a missing `COLD` var does not silently +run the cold case twice. + `SCENARIO=refuse` is the regression that matters most: a member who declines the signature must still complete the join and reach their dashboard. Never ship a join-flow change without it passing. diff --git a/qa/gate-e2e.mjs b/qa/gate-e2e.mjs index e43aff2..e33783a 100644 --- a/qa/gate-e2e.mjs +++ b/qa/gate-e2e.mjs @@ -1,128 +1,117 @@ -// End-to-end QA of the RM Circle required profile gate, driven through the real UI -// with a real inbox session. Boot: LOCAL (default :3399), session cookie in COOKIE. -import { pathToFileURL } from 'node:url'; -const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; -const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; -const B = process.env.LOCAL || 'http://127.0.0.1:3399'; -const TOKEN = process.env.TOKEN; // ctb.msid for position 21 -const TOKEN2 = process.env.TOKEN2; // ctb.msid for position 49 (already complete) -const ok = [], bad = []; -const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; -const browser = await chromium.launch(); -const ctxFor = async (tok, mobile) => { - const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } }); - if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); - return c; -}; -const gateOpen = p => p.evaluate(() => !!document.querySelector('#pgCard')); -const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); - -// ---------- 1. the gate on the member's OWN page ---------- -const ctx = await ctxFor(TOKEN); -const p = await ctx.newPage(); -await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2500); -t('gate appears on the owner\'s own page', await gateOpen(p)); -t('gate names the position and step', /position #21 . step 1 of 2/i.test(await cardText(p)), await cardText(p)); - -// cannot be dismissed -await p.keyboard.press('Escape'); await p.waitForTimeout(400); -t('Escape does not dismiss the required gate', await gateOpen(p)); -await p.mouse.click(8, 8); await p.waitForTimeout(400); -t('backdrop click does not dismiss the required gate', await gateOpen(p)); -t('no close button on the required gate', !(await p.evaluate(() => !!document.querySelector('#pgClose')))); - -// username validation -const typeUser = async v => { await p.fill('#pgUser', v); await p.click('#pgUserSave'); await p.waitForTimeout(600); }; -await typeUser('ab'); -t('rejects a short username', /3 to 20/.test(await cardText(p)), await cardText(p)); -await typeUser('12345'); -t('rejects digits only', /at least one letter/.test(await cardText(p))); -await typeUser('admin'); -t('rejects a reserved name', /reserved/.test(await cardText(p))); -await typeUser('takenname'); -t('accepts a good username and moves to step 2', /step 2 of 2/i.test(await cardText(p)), await cardText(p)); - -// email step -t('step 2 explains why and promises privacy', /never shown to other members/i.test(await cardText(p))); -await p.fill('#pgEmail', 'not-an-email'); await p.click('#pgMailSend'); await p.waitForTimeout(700); -t('rejects a malformed email', /does not look right/.test(await cardText(p)), await cardText(p)); -// grab the dev code straight off the API response -let devCode = null; -p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } }); -await p.fill('#pgEmail', 'qa-member@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400); -t('code is sent and the code box appears', /Code sent to qa\*\*\*@example\.com/.test(await cardText(p)), await cardText(p)); -t('the reply masks the address', /qa\*\*\*@example\.com/.test(await cardText(p))); -await p.fill('#pgCode', '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(800); -t('wrong code refused', /does not match/.test(await cardText(p)), await cardText(p)); -t('dev code exposed locally for the test', !!devCode, String(devCode)); -await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1200); -t('right code completes the profile', /all set, @takenname/i.test(await cardText(p)), await cardText(p)); -await p.waitForTimeout(3200); -t('gate closes itself after completion', !(await gateOpen(p))); - -// ---------- 2. the profile card, and editing ---------- -t('completion lands on the dashboard tab', await p.evaluate(() => { const d = document.getElementById('tabDash'); return !!d && getComputedStyle(d).display !== 'none'; })); -await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2000); -await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); -t('no gate on a completed profile', !(await gateOpen(p))); -const body = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); -t('dashboard shows the profile card', /Your member profile/.test(body), body.slice(0, 120)); -t('card shows the username', /@takenname/.test(body)); -t('card shows the confirmed email', /qa-member@example\.com/.test(body) && /confirmed/i.test(body)); -t('Change username button present', await p.evaluate(() => !!document.querySelector('#pgEditUser'))); -t('Change email button present', await p.evaluate(() => !!document.querySelector('#pgEditMail'))); -await p.click('#pgEditUser'); await p.waitForTimeout(900); -t('edit modal opens', await gateOpen(p)); -t('edit modal HAS a close button', await p.evaluate(() => !!document.querySelector('#pgClose'))); -await p.click('#pgClose'); await p.waitForTimeout(600); -t('edit modal can be dismissed', !(await gateOpen(p))); -await p.click('#pgEditUser'); await p.waitForTimeout(800); -await p.fill('#pgUser', 'renamedqa'); -await p.click('#pgUserSave'); await p.waitForTimeout(1400); -t('rename saves and closes', !(await gateOpen(p))); -await p.waitForTimeout(1200); -t('card shows the new username', /@renamedqa/.test(await p.evaluate(() => document.body.innerText))); - -// ---------- 3. scoping: never on someone else's page ---------- -const p2 = await ctx.newPage(); -await p2.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p2.waitForTimeout(2000); -await p2.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p2.waitForTimeout(1600); -t('no gate while browsing another position', !(await gateOpen(p2))); -t('no profile card on another position\'s page', !/Your member profile/.test(await p2.evaluate(() => document.body.innerText))); -t('the other page still renders', (await p2.evaluate(() => document.body.innerText)).length > 400); - -// an INCOMPLETE member browsing someone else's page must also not be gated -const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); -await p3.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); -t('incomplete member is not gated on a teammate page', !(await gateOpen(p3))); -await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2600); -t('incomplete member IS gated on their own page', await gateOpen(p3)); -t('their gate starts at the username step', /step 1 of 2/i.test(await cardText(p3)), await cardText(p3)); -await p3.fill('#pgUser', 'seeded49'); await p3.click('#pgUserSave'); await p3.waitForTimeout(900); -t('seeded email is pre-filled at step 2', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : ''; }) === 'seeded49@example.com', await p3.evaluate(() => { const i = document.querySelector('#pgEmail'); return i ? i.value : 'no input'; })); -t('copy tells them it is already on file', /already have this one on file/i.test(await cardText(p3)), await cardText(p3)); - -// ---------- 4. visitors ---------- -const ctxAnon = await ctxFor(null); const p4 = await ctxAnon.newPage(); -const anon401 = []; -p4.on('response', r => { if (r.status() === 401 && r.url().includes('/profile')) anon401.push(r.url()); }); -for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) { - await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1600); - const txt = await p4.evaluate(() => document.body.innerText); - t('visitor: ' + u + ' renders with no gate', !(await gateOpen(p4)) && txt.length > 300, 'len ' + txt.length); -} -t('visitor sees no 401 from the profile endpoint', anon401.length === 0, anon401.join(',')); - -// ---------- 5. phone width ---------- -const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage(); -await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2600); -t('gate renders on a phone', await gateOpen(p5)); -const fits = await p5.evaluate(() => { const c = document.querySelector('#pgCard'); if (!c) return false; const r = c.getBoundingClientRect(); return r.width <= window.innerWidth && r.left >= 0; }); -t('gate fits the phone viewport', fits); -const noHScroll = await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2); -t('no horizontal scroll on a phone', noHScroll); - -console.log('\nPASS ' + ok.length); -for (const b of bad) console.log('FAIL ' + b); -await browser.close(); -process.exit(bad.length ? 1 : 0); +// End-to-end QA of the RM Circle OPTIONAL member profile (Marty + Manson, 2026-09-17). +// +// The rule this suite defends: contact details are NEVER required. Nothing about +// holding a position, getting paid, reading the org, the training or the tools may +// depend on them. The only place they are asked for is the inbox, where a message +// cannot be delivered without them, and even there it is an invitation. +// +// Run: LOCAL=... TOKEN= TOKEN2= node qa/gate-e2e.mjs +import { pathToFileURL } from 'node:url'; +const PW = 'D:/Projects/MarketingAgent/qa-tester/node_modules/playwright'; +const { chromium } = (await import(pathToFileURL(PW + '/index.js').href)).default; +const B = process.env.LOCAL || 'http://127.0.0.1:3399'; +const TOKEN = process.env.TOKEN; // position 21, no profile yet +const TOKEN2 = process.env.TOKEN2; // position 49, seeded email, no username +const ok = [], bad = []; +const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); }; +const browser = await chromium.launch(); +const ctxFor = async (tok, mobile) => { + const c = await browser.newContext(mobile ? { viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true } : { viewport: { width: 1280, height: 950 } }); + // The event flyer and the upgrade promo are full-screen overlays that legitimately + // cover /my once per browser/session. They are not what this suite tests, and they + // swallow clicks, so mark them already-seen instead of racing their fade-out. + await c.addInitScript(() => { + const g = Storage.prototype.getItem; + Storage.prototype.getItem = function (k) { + if (/^rmc-promo-/.test(k)) return 'seen'; + if (/^rmc-announce-/.test(k)) return 'done'; + return g.call(this, k); + }; + }); + if (tok) await c.addCookies([{ name: 'ctb.msid', value: tok, url: B }]); + return c; +}; +const modalOpen = p => p.evaluate(() => !!document.querySelector('#pgCard')); +const cardText = p => p.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); +const openDash = async p => { await p.evaluate(() => { const b = document.querySelector('.mp-tab[data-tab="dash"]'); if (b) b.click(); }); await p.waitForTimeout(1800); }; + +// ---------- 1. nothing is forced ---------- +const ctx = await ctxFor(TOKEN); +const p = await ctx.newPage(); +await p.goto(B + '/my/21', { waitUntil: 'networkidle' }); await p.waitForTimeout(2600); +t('NO automatic modal on the member\'s own page', !(await modalOpen(p))); +await openDash(p); +t('still no modal after opening the dashboard', !(await modalOpen(p))); +const body1 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('a dismissable invitation card is shown instead', /Get a note when you get paid/i.test(body1), body1.slice(0, 120)); +t('the card says it is optional', /optional/i.test(body1)); +t('the card promises nothing else changes', /work exactly the same without it/i.test(body1)); +t('"Not now" is offered', await p.evaluate(() => !!document.getElementById('pcNo'))); + +// declining costs nothing and snoozes +await p.click('#pcNo'); await p.waitForTimeout(600); +t('declining removes the card', !(await p.evaluate(() => !!document.getElementById('pcNo')))); +t('declining leaves the dashboard fully usable', (await p.evaluate(() => document.body.innerText)).length > 500); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); +t('the card stays away after declining (snoozed)', !(await p.evaluate(() => !!document.getElementById('pcNo')))); +t('and still no modal', !(await modalOpen(p))); + +// ---------- 2. opting in works, and the dialog can be abandoned ---------- +await p.evaluate(() => { try { localStorage.removeItem('rmc.profileSnooze'); } catch (e) {} }); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); +t('the card returns once the snooze is cleared', await p.evaluate(() => !!document.getElementById('pcGo'))); +await p.click('#pcGo'); await p.waitForTimeout(1000); +t('the dialog opens on request', await modalOpen(p)); +t('the dialog can always be closed', await p.evaluate(() => !!document.getElementById('pgClose'))); +t('it opens at the username step, worded as optional', /changeable any time/i.test(await cardText(p)), await cardText(p)); +await p.click('#pgClose'); await p.waitForTimeout(600); +t('abandoning the dialog is allowed', !(await modalOpen(p))); + +// complete it for real +await p.click('#pcGo'); await p.waitForTimeout(900); +let devCode = null; +p.on('response', async r => { if (r.url().includes('/profile/email-start')) { try { const j = await r.json(); if (j.devCode) devCode = j.devCode; } catch (e) {} } }); +await p.fill('#pgUser', 'optin21'); await p.click('#pgUserSave'); await p.waitForTimeout(1100); +// the regression that matters: saving a username must ADVANCE to the email step, +// not close the dialog. The dismissable flag used to double as "single-field edit". +t('saving the username advances to the email step', await p.evaluate(() => !!document.getElementById('pgEmail')), await cardText(p)); +t('the email step leads with payout alerts, not messaging', /payout lands in your wallet/i.test(await cardText(p)), await cardText(p)); +await p.fill('#pgEmail', 'optin@example.com'); await p.click('#pgMailSend'); await p.waitForTimeout(1400); +await p.fill('#pgCode', devCode || '000000'); await p.click('#pgCodeConfirm'); await p.waitForTimeout(1500); +t('opting in completes', /all set, @optin21/i.test(await cardText(p)) || !(await modalOpen(p)), await cardText(p)); +await p.waitForTimeout(3200); +await p.reload({ waitUntil: 'networkidle' }); await p.waitForTimeout(2200); await openDash(p); +const body2 = await p.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('the invitation card is gone once done', !/Get a note when you get paid/i.test(body2)); +t('the profile card shows their details', /Your member profile/.test(body2) && /@optin21/.test(body2), body2.slice(0, 120)); +t('they can still change it later', await p.evaluate(() => !!document.getElementById('pgEditUser'))); + +// ---------- 3. the inbox asks only where it matters ---------- +const ctx2 = await ctxFor(TOKEN2); const p3 = await ctx2.newPage(); +await p3.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p3.waitForTimeout(2400); await openDash(p3); +t('no modal for the member without contact details', !(await modalOpen(p3))); +const body3 = await p3.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +t('the inbox explains why it needs an address', /Messages reach you here only/i.test(body3), body3.slice(0, 140)); +t('the inbox ask is phrased as optional', /Optional, private, removable/i.test(body3)); + +// ---------- 4. nothing changed for visitors or shared links ---------- +const anon = await ctxFor(null); const p4 = await anon.newPage(); +const anon401 = []; +p4.on('response', r => { if (r.status() === 401 && /\/profile|\/reach/.test(r.url())) anon401.push(r.url()); }); +for (const u of ['/my/21', '/my/49', '/join/21', '/fast-start?id=21', '/generation-pay?id=21', '/flyers?id=21']) { + await p4.goto(B + u, { waitUntil: 'domcontentloaded' }); await p4.waitForTimeout(1500); + const txt = await p4.evaluate(() => document.body.innerText); + t('visitor: ' + u + ' renders, no prompt of any kind', !(await modalOpen(p4)) && !/Get a note when you get paid/i.test(txt) && txt.length > 300, 'len ' + txt.length); +} +t('visitor triggers no 401s', anon401.length === 0, anon401.join(',')); + +// ---------- 5. phone ---------- +const ctxM = await ctxFor(TOKEN2, true); const p5 = await ctxM.newPage(); +await p5.goto(B + '/my/49', { waitUntil: 'networkidle' }); await p5.waitForTimeout(2400); await openDash(p5); +t('no forced modal on a phone', !(await modalOpen(p5))); +t('no horizontal scroll on a phone', await p5.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 2)); + +console.log('PASS ' + ok.length); +for (const b of bad) console.log('FAIL ' + b); +await browser.close(); +process.exit(bad.length ? 1 : 0); diff --git a/qa/join-flow-e2e.mjs b/qa/join-flow-e2e.mjs index 17aafcd..7997a39 100644 --- a/qa/join-flow-e2e.mjs +++ b/qa/join-flow-e2e.mjs @@ -33,6 +33,16 @@ const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' const browser = await chromium.launch(); const ctx = await browser.newContext({ viewport: { width: 1280, height: 950 } }); +// The event flyer and upgrade promo are full-screen overlays that legitimately cover +// /my once per browser/session. They are not under test here and they swallow clicks. +await ctx.addInitScript(() => { + const g = Storage.prototype.getItem; + Storage.prototype.getItem = function (k) { + if (/^rmc-promo-/.test(k)) return 'seen'; + if (/^rmc-announce-/.test(k)) return 'done'; + return g.call(this, k); + }; +}); const page = await ctx.newPage(); // real signing, called from the page (exposeFunction sidesteps the page's strict CSP) @@ -108,16 +118,26 @@ if (SCENARIO === 'sign') { t('a signature was requested right after the join', (qa.signAsked || 0) >= 1, JSON.stringify(qa)); t('an inbox session was created', hasSession, cookies.map(c => c.name).join(',')); await page.waitForTimeout(2500); - const gate = await page.evaluate(() => !!document.querySelector('#pgCard')); - t('the profile gate appears on their dashboard', gate); - const card = await page.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); - t('the gate is for THEIR position', new RegExp('position #' + NEW_ID, 'i').test(card), card.slice(0, 90)); + // Since 2026-09-17 contact details are optional, so a brand-new member must NOT be + // met by a modal. They get a dismissable invitation on their own dashboard instead. + t('NO modal is forced on a brand-new member', !(await page.evaluate(() => !!document.querySelector('#pgCard')))); + const invite = await page.evaluate(() => !!document.getElementById('pcGo')); + t('the optional invitation is offered instead', invite); + t('and it can be declined', await page.evaluate(() => !!document.getElementById('pcNo'))); + if (invite) { + await page.click('#pcGo'); await page.waitForTimeout(1400); + const card = await page.evaluate(() => { const c = document.querySelector('#pgCard'); return c ? c.innerText.replace(/\s+/g, ' ') : ''; }); + t('opening it addresses THEIR position', new RegExp('position #' + NEW_ID, 'i').test(card), card.slice(0, 90)); + t('and they can close it again', await page.evaluate(() => !!document.getElementById('pgClose'))); + } } else { t('the signature was refused by the wallet', (qa.signRefused || 0) >= 1, JSON.stringify(qa)); t('REGRESSION: the join still completed and redirected', /\/my\//.test(page.url()), page.url()); t('no inbox session, as expected', !hasSession); const gate = await page.evaluate(() => !!document.querySelector('#pgCard')); - t('no gate without a session, page still usable', !gate); + t('no modal without a session, page still usable', !gate); + t('and no invitation either, since we cannot know who they are', + !(await page.evaluate(() => !!document.getElementById('pcGo')))); const txt = await page.evaluate(() => document.body.innerText); t('the dashboard still renders for them', txt.length > 300, 'len ' + txt.length); } diff --git a/qa/reseed.sh b/qa/reseed.sh new file mode 100644 index 0000000..606a820 --- /dev/null +++ b/qa/reseed.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Rebuild a throwaway data dir + server for the profile E2E suite. +# The suite completes a profile for 21, so it is NOT idempotent: reseed every run. +# +# Two rules learned the hard way, do not undo them: +# * NEVER name the data-dir variable TMP/TEMP/TMPDIR. On Windows those are already +# set to the system temp directory, so `${TMP:-default}` silently inherits it and +# the rm -rf below wipes the machine's temp folder instead of the throwaway dir. +# * NEVER add pkill/taskkill. A broad pattern kills the tooling running this script. +# Stop the old server through the pid file instead. +# +# Usage: bash qa/reseed.sh (then read $RMCQA_DIR/tokens.txt) +set -e +RMCQA_DIR=${RMCQA_DIR:-/d/tmp/rmc-e2e-data} +PORT=${PORT:-3399} +case "$RMCQA_DIR" in + /d/tmp/*|/D/tmp/*) ;; + *) echo "refusing: RMCQA_DIR must live under /d/tmp (got '$RMCQA_DIR')"; exit 2 ;; +esac +if [ -f "$RMCQA_DIR/server.pid" ]; then kill "$(cat "$RMCQA_DIR/server.pid")" 2>/dev/null || true; sleep 2; fi +rm -rf "$RMCQA_DIR"; mkdir -p "$RMCQA_DIR" +if [ -f /d/tmp/rmc-config-cache.json ]; then cp /d/tmp/rmc-config-cache.json "$RMCQA_DIR/config.json"; +else ssh -o StrictHostKeyChecking=no root@coolify.saasy.top \ + "docker exec \$(docker ps -q --filter name=kr445fqc) cat /app/data/config.json" > "$RMCQA_DIR/config.json" + cp "$RMCQA_DIR/config.json" /d/tmp/rmc-config-cache.json; fi +echo '[]' > "$RMCQA_DIR/sponsors.json" +echo '{"49":{"email":"seeded49@example.com","ts":"x"}}' > "$RMCQA_DIR/member-alerts.json" +node -e "const m=require('./messages.js'); m.init({dataDir:process.argv[1],chain:require('./chain.js'),isProd:false}); require('fs').writeFileSync(process.argv[1]+'/tokens.txt', m.mintSession(21)+'\n'+m.mintSession(49));" "$RMCQA_DIR" +PORT=$PORT DATA_DIR=$RMCQA_DIR ADMIN_PASSWORD=localtest node server.js > "$RMCQA_DIR/server.log" 2>&1 & +echo $! > "$RMCQA_DIR/server.pid" +for i in $(seq 1 40); do + if [ "$(curl -s -o /dev/null -w %{http_code} "http://127.0.0.1:$PORT/my/21" 2>/dev/null)" = "200" ]; then echo "ready on $PORT"; exit 0; fi + sleep 1 +done +echo "server did not come up"; tail -20 "$RMCQA_DIR/server.log"; exit 1 diff --git a/server.js b/server.js index f3453af..e11a30f 100644 --- a/server.js +++ b/server.js @@ -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/ 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/ 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/ 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/ 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.'});