Merge origin/master (TURN/coturn + BizGaze-only login) into feature tree

Resolved conflicts in routes.js and share.html: kept the dev tree's superset
(ALLOW_LOCAL_LOGIN dev escape, avatar sync, richer login errors) which already
includes the incoming production BizGaze-only behavior; took the more descriptive
incoming comments. Restored 5 untracked modules (chat, calls, directory,
reminders, webhooks) that were missing from disk — required by routes/signaling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:27:59 +05:30
12 changed files with 523 additions and 13 deletions
+151
View File
@@ -0,0 +1,151 @@
// Shared group calls: one live call per group. Members join without a code; the call
// ends (with a duration line in the chat) when the last participant's mesh room empties.
const fs = require('fs');
const path = require('path');
const R = require('./repos');
const A = require('./auth');
const CHAT = require('./chat');
const { TRANS_DIR } = require('./config');
const { meetingRooms, groupCalls, roomToGroupCall, dmCalls, roomToDmCall, roomHost, transcriptBuffers, transcriptSubs } = require('./presence');
const now = () => Date.now();
const pairKey = (a, b) => [a, b].sort().join('|');
// Resolve a room's meeting context (group / scheduled meeting / title) for labelling recordings.
function meetingContext(room) {
const ctx = { groupId: null, meetingId: null, title: 'Meeting' };
try {
const sched = R.scheduledMeetings.byCode(room);
if (sched) { ctx.meetingId = sched.id; ctx.groupId = sched.group_id || null; ctx.title = sched.title || 'Meeting'; }
} catch (_) {}
if (!ctx.groupId) { const gid = roomToGroupCall.get(room); if (gid) ctx.groupId = gid; }
if (ctx.groupId && ctx.title === 'Meeting') { try { const g = R.conversations.byId(ctx.groupId); if (g) ctx.title = g.name || 'Group'; } catch (_) {} }
if (!ctx.groupId && !ctx.meetingId && roomToDmCall.has(room)) ctx.title = 'Direct Call';
return ctx;
}
// Save the FULL shared conversation transcript as a PRIVATE copy for each subscriber. onlyUserId
// finalizes just that subscriber (on their leave / opt-out); omit to flush all remaining (room end).
// Must run BEFORE endCallByRoom (which clears the room→meeting maps meetingContext relies on).
function finalizeTranscript(room, onlyUserId) {
const subs = transcriptSubs.get(room); if (!subs || !subs.size) { if (!onlyUserId) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } return; }
const buf = transcriptBuffers.get(room) || [];
const ids = onlyUserId ? (subs.has(onlyUserId) ? [onlyUserId] : []) : [...subs];
if (ids.length && buf.length) {
const ctx = meetingContext(room);
const lines = buf.map((s) => { const ts = new Date(s.t); const hh = String(ts.getHours()).padStart(2, '0'), mm = String(ts.getMinutes()).padStart(2, '0'); return '[' + hh + ':' + mm + '] ' + s.speaker + ': ' + s.text; });
const body = ctx.title + ' — transcript\n' + new Date(buf[0].t).toLocaleString() + '\n\n' + lines.join('\n') + '\n';
for (const uid of ids) {
let user = null; try { user = R.users.byId(uid); } catch (_) {}
if (!user) { subs.delete(uid); continue; }
const id = A.id(); const file = 'm_' + id + '.txt';
try { fs.writeFileSync(path.join(TRANS_DIR, file), body); } catch (e) { continue; }
// groupId null → private to its creator (see canSeeRec / /mrec auth).
R.recordings.create({ id, teamId: user.team_id, room, groupId: null, meetingId: ctx.meetingId, title: ctx.title, kind: 'transcript', file, mime: 'text/plain', size: null, durationMs: null, createdBy: uid, createdByName: user.name || user.email });
subs.delete(uid);
}
} else { ids.forEach((uid) => subs.delete(uid)); }
if (!subs.size) { transcriptBuffers.delete(room); transcriptSubs.delete(room); } // last subscriber done
}
function fmtDur(ms) { const s = Math.max(0, Math.round(ms / 1000)); const m = Math.floor(s / 60); return m ? (m + 'm ' + (s % 60) + 's') : (s + 's'); }
function broadcast(group, evt) { try { for (const mid of R.conversations.members(group)) CHAT.pushToUser(mid, evt); } catch (_) {} }
// Post a centered activity line into the group (system sender → no ping on clients).
function postSystem(group, teamId, text) {
const id = A.id();
R.messages.send({ id, teamId, senderId: '__system__', recipientId: '', body: text, conversationId: group });
const m = R.messages.byId(id);
broadcast(group, { type: 'chat-message', message: { id: m.id, from: '__system__', conversation_id: group, body: m.body, created_at: m.created_at, system: true } });
}
function startGroupCall(group, teamId, user) {
const existing = groupCalls.get(group);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const call = { room, startedAt: now(), startedBy: user.id, startedByName: user.name || user.email };
// Log the call as a meeting so it appears under Past meetings (history) with the group name.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: group, roomCode: room, title: 'Group call', description: null, scheduledAt: now(), createdBy: user.id }); call.historyId = hid; call.teamId = teamId; } catch (_) {}
groupCalls.set(group, call); roomToGroupCall.set(room, group); roomHost.set(room, user.id); // creator = host
postSystem(group, teamId, '📞 ' + call.startedByName + ' started a group call');
let gName = 'Group'; try { const g = R.conversations.byId(group); if (g) gName = g.name || 'Group'; } catch (_) {}
broadcast(group, { type: 'group-call', group, active: true, room, by: user.id, startedByName: call.startedByName, groupName: gName });
return { room, active: true };
}
// Called from signaling when a mesh room empties — ends the group call if this room was one.
function endGroupCallByRoom(room) {
const group = roomToGroupCall.get(room);
if (!group) return;
const call = groupCalls.get(group);
roomToGroupCall.delete(room); groupCalls.delete(group); roomHost.delete(room);
if (call) {
let teamId = call.teamId; try { const g = R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)); } } catch (_) {}
if (call.historyId && teamId) { try { R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
broadcast(group, { type: 'group-call', group, active: false, room });
}
}
// 1:1 (DM) call. Notifies both parties (state + a chat line) so the callee sees "Join".
function startDmCall(me, otherId, teamId) {
const key = pairKey(me.id, otherId);
const existing = dmCalls.get(key);
if (existing) return { room: existing.room, active: true, already: true };
let room; do { room = A.numericCode(6); } while (meetingRooms.has(room));
meetingRooms.set(room, new Map());
const byName = me.name || me.email;
const call = { room, startedAt: now(), startedBy: me.id, startedByName: byName, users: [me.id, otherId], teamId };
// Log to history (both participants) so the call shows under Past meetings with its transcript.
try { const hid = A.id(); R.scheduledMeetings.create({ id: hid, teamId, groupId: null, roomCode: room, title: 'Direct Call', description: null, scheduledAt: now(), createdBy: me.id, participants: [me.id, otherId] }); call.historyId = hid; } catch (_) {}
dmCalls.set(key, call); roomToDmCall.set(room, key); roomHost.set(room, me.id); // caller = host
// A viewer-relative activity line: the caller sees "You started a call", the callee sees the name.
const mid = A.id();
R.messages.send({ id: mid, teamId, senderId: me.id, recipientId: otherId, body: '📞 Started a call', msgType: 'call-start' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: me.id, to: otherId, conversation_id: null, body: m.body, created_at: m.created_at, system: true, evt: 'call-start', byName };
try { CHAT.pushToUser(otherId, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'chat-message', message: dto }); } catch (_) {}
try { CHAT.pushToUser(otherId, { type: 'dm-call', active: true, room, with: me.id, by: me.id, byName }); } catch (_) {}
try { CHAT.pushToUser(me.id, { type: 'dm-call', active: true, room, with: otherId, by: me.id, byName }); } catch (_) {}
return { room, active: true };
}
function endDmCallByRoom(room, silent) {
const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key);
roomToDmCall.delete(room); dmCalls.delete(key); roomHost.delete(room);
if (!call) return;
if (call.historyId && call.teamId) { try { R.scheduledMeetings.end(call.historyId, call.teamId); } catch (_) {} } // mark history past
// "Call ended · duration" activity line in the DM (shown to both) — skipped on decline.
if (!silent) try {
const mid = A.id(); const body = '📞 Call ended · ' + fmtDur(now() - call.startedAt);
R.messages.send({ id: mid, teamId: call.teamId, senderId: call.startedBy, recipientId: call.users.find((u) => u !== call.startedBy) || '', body, msgType: 'call-end' });
const m = R.messages.byId(mid); const dto = { id: m.id, from: call.startedBy, to: m.recipient_id, conversation_id: null, body, created_at: m.created_at, system: true, evt: 'call-end' };
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
} catch (_) {}
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, with: call.users[1 - i], room }); } catch (_) {} });
}
// Called from signaling when any mesh room empties.
function endCallByRoom(room) { endGroupCallByRoom(room); endDmCallByRoom(room); }
// Callee declines a 1:1 call: post "Call declined" into the DM, drop the waiting caller, end it.
function declineDmCall(room, byUser) {
const key = roomToDmCall.get(room); if (!key) return { ok: false };
const call = dmCalls.get(key); if (!call) return { ok: false };
const callerId = call.users.find((id) => id !== byUser.id) || call.startedBy;
try {
const mid = A.id();
R.messages.send({ id: mid, teamId: byUser.team_id, senderId: byUser.id, recipientId: callerId, body: '📞 Call declined', msgType: 'call-end' });
const mm = R.messages.byId(mid); const dto = { id: mm.id, from: byUser.id, to: callerId, conversation_id: null, body: mm.body, created_at: mm.created_at, system: true, evt: 'call-end' };
CHAT.pushToUser(callerId, { type: 'chat-message', message: dto });
CHAT.pushToUser(byUser.id, { type: 'chat-message', message: dto });
} catch (_) {}
// Drop the caller who's still waiting in the (otherwise empty) mesh room.
const peers = meetingRooms.get(room);
if (peers) { for (const [, p] of peers) { if (p.ws.readyState === 1) { try { p.ws.send(JSON.stringify({ type: 'meeting-ended' })); } catch (_) {} p._meetingRoom = null; } } meetingRooms.delete(room); }
endDmCallByRoom(room, true); // silent: we already posted "Call declined"
return { ok: true };
}
module.exports = { startGroupCall, startDmCall, endGroupCallByRoom, endDmCallByRoom, endCallByRoom, declineDmCall, finalizeTranscript, meetingContext, fmtDur, pairKey };
+31
View File
@@ -0,0 +1,31 @@
// Chat presence + real-time delivery. A logged-in user opens a WebSocket and sends
// `chat-hello`; signaling.js registers the socket here. Messages are persisted over HTTP
// (routes.js) and pushed live to the recipient's sockets via pushToUser().
const { chatClients } = require('./presence');
function register(userId, ws) {
if (!chatClients.has(userId)) chatClients.set(userId, new Set());
chatClients.get(userId).add(ws);
ws._chatUserId = userId;
}
function unregister(ws) {
const id = ws && ws._chatUserId;
if (!id) return;
const set = chatClients.get(id);
if (set) { set.delete(ws); if (!set.size) chatClients.delete(id); }
}
function isOnline(userId) {
const s = chatClients.get(userId);
return !!(s && s.size);
}
function pushToUser(userId, obj) {
const s = chatClients.get(userId);
if (!s) return;
const data = JSON.stringify(obj);
for (const ws of s) { if (ws.readyState === 1) { try { ws.send(data); } catch (_) {} } }
}
module.exports = { register, unregister, isOnline, pushToUser };
+57
View File
@@ -0,0 +1,57 @@
// BizGaze user-directory search (cross-tenant). The auth token is kept SERVER-SIDE only — the
// browser calls /api/directory/search and never sees the token. Configure via env in production:
// BIZGAZE_DIRECTORY_URL (base, the search term is appended url-encoded)
// BIZGAZE_DIRECTORY_TOKEN (the "stat ..." Authorization header value)
const DEFAULT_URL = 'https://app.bizgaze.com/apis/v4/bizgaze/integrations/users_chatsearch/get_usersforchatsearch/searchterm/';
const DEFAULT_TOKEN = 'stat 3cd2e190b4db448496ae316b155d2441';
function baseUrl() { return process.env.BIZGAZE_DIRECTORY_URL || DEFAULT_URL; }
function token() { return process.env.BIZGAZE_DIRECTORY_TOKEN || DEFAULT_TOKEN; }
function enabled() { return !!(baseUrl() && token()); }
// Pull a field from an object by any of several case-insensitive key names.
function field(o, names) {
const keys = Object.keys(o || {});
for (const want of names) { for (const k of keys) { if (k.toLowerCase() === want) { const v = o[k]; if (v != null && v !== '') return String(v); } } }
return '';
}
// BizGaze responses vary (raw array, or wrapped in Result/data, sometimes a JSON string). Normalize.
function toArray(data) {
let d = data;
if (typeof d === 'string') { try { d = JSON.parse(d); } catch (_) { return []; } }
if (Array.isArray(d)) return d;
if (d && typeof d === 'object') {
for (const key of ['Result', 'result', 'data', 'Data', 'records', 'Records', 'items', 'Items']) {
if (d[key] != null) { let v = d[key]; if (typeof v === 'string') { try { v = JSON.parse(v); } catch (_) {} } if (Array.isArray(v)) return v; }
}
}
return [];
}
function pick(o) {
return {
id: field(o, ['userid', 'id', 'contactid', 'partyid', 'recordid']),
name: field(o, ['fullname', 'name', 'displayname', 'username', 'contactname', 'firstname']),
email: field(o, ['email', 'emailaddress', 'emailid', 'mail']),
phone: field(o, ['mobile', 'mobilenumber', 'phone', 'phonenumber', 'contactno', 'contactnumber']),
avatar: field(o, ['photourl', 'photo', 'avatar', 'imageurl', 'profilepic', 'profileimage']),
org: field(o, ['organization', 'organisation', 'company', 'tenantname', 'orgname']),
};
}
async function search(term) {
if (!enabled() || !term || term.trim().length < 2) return [];
const url = baseUrl() + encodeURIComponent(term.trim());
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), 8000);
try {
const r = await fetch(url, { headers: { Authorization: token(), Accept: 'application/json' }, signal: ctrl.signal });
if (!r.ok) return [];
const data = await r.json().catch(() => null);
return toArray(data).map(pick).filter((x) => x.name || x.email || x.phone).slice(0, 25);
} catch (_) { return []; }
finally { clearTimeout(to); }
}
module.exports = { search, enabled };
+17 -4
View File
@@ -1,17 +1,30 @@
{
"name": "remote-access-server",
"version": "0.2.0",
"name": "bizgaze-support-server",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remote-access-server",
"version": "0.2.0",
"name": "bizgaze-support-server",
"version": "2.0.0",
"dependencies": {
"ws": "^8.18.0"
},
"engines": {
"node": ">=22.5.0"
},
"optionalDependencies": {
"nodemailer": "^6.9.14"
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"license": "MIT-0",
"optional": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/ws": {
+2 -1
View File
@@ -175,7 +175,8 @@ async function startStreaming(){
chatChannel.onmessage=(e)=>{try{addChat(JSON.parse(e.data));}catch(_){}};
const offer=await pc.createOffer(); await pc.setLocalDescription(offer);
ws.send(JSON.stringify({type:'offer',sessionId,sdp:pc.localDescription}));
// Watchdog: clear failure message instead of a blank screen if no path establishes.
// Watchdog: if we can't establish the peer connection in time, show a clear reason
// instead of a blank screen (covers networks with no usable path even via TURN).
clearTimeout(window.__connWatch);
window.__connWatch=setTimeout(()=>{ if(pc && pc.connectionState!=='connected' && !sessionOver){ try{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));}catch(_){} endShareSession("Couldn't connect on this network — it may be blocking screen sharing. Try a different network (e.g. mobile data / hotspot), then tap below for a new code."); } }, 20000);
localStream.getVideoTracks()[0].onended=()=>{ws.send(JSON.stringify({type:'end-session',sessionId,reason:'customer-ended'}));teardown();};
+25
View File
@@ -0,0 +1,25 @@
// Fires a one-shot "starts in ~10 minutes" reminder to a scheduled meeting's host,
// group members, and invited participants. Runs on a 60s tick; marks each meeting reminded.
const R = require('./repos');
const CHAT = require('./chat');
function tick() {
try {
const now = Date.now();
const due = R.scheduledMeetings.dueForReminder(now, now + 10 * 60 * 1000); // starting within 10 min
for (const s of due) {
const recipients = new Set([s.created_by]);
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
invited.forEach((id) => recipients.add(id));
if (s.group_id) { try { R.conversations.members(s.group_id).forEach((m) => recipients.add(m)); } catch (_) {} }
const evt = { type: 'meeting-reminder', meeting: { id: s.id, title: s.title, scheduledAt: s.scheduled_at, room: s.room_code } };
recipients.forEach((uid) => { try { CHAT.pushToUser(uid, evt); } catch (_) {} });
R.scheduledMeetings.markReminded(s.id);
}
} catch (_) { /* never let the timer die */ }
}
let timer = null;
function start() { if (!timer) timer = setInterval(tick, 60 * 1000); }
start();
module.exports = { start, tick };
+18 -8
View File
@@ -124,7 +124,8 @@ route('POST', '/api/mfa/enable', async (req, res) => {
// Provision (or refresh) a local user from a successful BizGaze identity check.
// The local row exists so sessions, audit, and team-scoped data work; BizGaze stays
// the source of truth for credentials (the local password is random + unused).
// Emails that must always be admins regardless of what BizGaze returns (lockout safety net).
// Emails that must always be admins regardless of what BizGaze returns (safety net so an
// admin can't be locked out of the report if BizGaze doesn't flag them isAdmin). Optional.
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
function provisionFromBizgaze(email, bz) {
const role = (bz.isAdmin || ADMIN_EMAILS.includes(String(email).toLowerCase())) ? 'admin' : 'technician';
@@ -144,8 +145,10 @@ function provisionFromBizgaze(email, bz) {
return R.users.byId(existing.id);
}
// Login: validates locally first (seeded/bootstrap accounts), then against BizGaze
// (the identity provider) when BIZGAZE_LOGIN_URL is configured. Sets a session cookie.
// Login: when BizGaze (BIZGAZE_LOGIN_URL) is configured it is the ONLY authority — the
// credentials are verified against BizGaze and the user is provisioned/synced locally
// (local passwords are not accepted). Without it (dev/tests) the local password is
// checked. Sets a session cookie.
route('POST', '/api/login', async (req, res) => {
const { email, password, remember } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and password required' });
@@ -154,7 +157,8 @@ route('POST', '/api/login', async (req, res) => {
// Production: when BizGaze is the IdP, verify ONLY against BizGaze (no local-password
// fallback) so stale in-app accounts can't shadow a BizGaze login and everyone lands in
// the same tenant. Local accounts stay usable for dev/testing via ALLOW_LOCAL_LOGIN=1.
// the same tenant (admins then see all sessions). Local accounts stay usable for
// dev/testing via ALLOW_LOCAL_LOGIN=1.
const bizgazeOnly = BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1';
let u = null, bzMsg = null;
if (bizgazeOnly) {
@@ -164,6 +168,8 @@ route('POST', '/api/login', async (req, res) => {
u = provisionFromBizgaze(email, bz);
if (u && u.active === 0) return json(res, 403, { error: 'This account has been deactivated' });
} else {
// Local/dev/tests, or ALLOW_LOCAL_LOGIN=1: verify the local password, then fall back
// to BizGaze if a local password isn't set/correct (so SSO users can still sign in).
u = (existing && A.verifyPassword(password, existing.pw_salt, existing.pw_hash)) ? existing : null;
if (!u) {
const bz = await BZ.validateLogin(email, password);
@@ -234,8 +240,11 @@ route('GET', '/api/setup-state', async (req, res) => {
});
// ICE servers for WebRTC. Always includes a public STUN; adds our TURN relay if
// configured. TURN_SECRET (coturn use-auth-secret) -> time-limited HMAC credentials
// (no permanent password exposed); otherwise static TURN_USERNAME + TURN_CREDENTIAL.
// configured. Two credential modes:
// - Shared secret (recommended, coturn `use-auth-secret`): set TURN_SECRET and we mint
// time-limited credentials per request (no permanent password is ever handed out, so
// outsiders can't reuse your relay). Optional TURN_TTL seconds (default 24h).
// - Static: set TURN_USERNAME + TURN_CREDENTIAL for a fixed long-term credential.
route('GET', '/api/ice', async (req, res) => {
const iceServers = [{ urls: 'stun:stun.l.google.com:19302' }];
if (process.env.TURN_URLS) {
@@ -244,7 +253,7 @@ route('GET', '/api/ice', async (req, res) => {
let credential = process.env.TURN_CREDENTIAL || '';
if (process.env.TURN_SECRET) {
const ttl = parseInt(process.env.TURN_TTL || '86400', 10);
username = String(Math.floor(Date.now() / 1000) + ttl);
username = String(Math.floor(Date.now() / 1000) + ttl); // coturn expects "<expiry>"
credential = require('crypto').createHmac('sha1', process.env.TURN_SECRET).update(username).digest('base64');
}
iceServers.push({ urls, username, credential });
@@ -299,7 +308,8 @@ route('POST', '/api/users', async (req, res) => {
if (!u) return json(res, 401, { error: 'unauthorized' });
if (u.role !== 'admin') return json(res, 403, { error: 'only admins can add agents' });
// With BizGaze as the sole IdP, logins are created in BizGaze, not here (creating local
// accounts is what previously shadowed BizGaze and split tenants). Allowed in dev.
// accounts is what previously shadowed BizGaze and split tenants). Allowed in dev via
// ALLOW_LOCAL_LOGIN=1.
if (BZ.isEnabled() && process.env.ALLOW_LOCAL_LOGIN !== '1') return json(res, 400, { error: 'Logins are managed in BizGaze. Add the user there; they appear here on first sign-in.' });
const { email, password, name, role } = await readBody(req);
if (!email || !password) return json(res, 400, { error: 'email and temporary password required' });
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
// One-time PRODUCTION migration for "BizGaze-only logins".
//
// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
// change, every user then signs in through BizGaze and is provisioned into the same
// tenant — which restores the admin's "see all sessions" report.
//
// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
// (i.e. created locally via register/console, not provisioned by a BizGaze login).
//
// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
// Dry run : node scripts/migrate-bizgaze-only.js
// Apply : node scripts/migrate-bizgaze-only.js --apply
// Honors DB_PATH (same env var the server uses).
const db = require('../db');
const APPLY = process.argv.includes('--apply');
const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
const ssoEmails = new Set(
db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
.all().map((r) => r.e),
);
const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
console.log('=== Teams ===');
for (const t of db.prepare('SELECT id,name FROM teams').all()) {
const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
console.log(` ${t.id} ${t.name} (${uc} users)`);
}
console.log('\n=== Users ===');
console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`);
console.log('\n KEEP (already BizGaze-provisioned):');
keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
console.log('\n DELETE (local / pre-BizGaze):');
remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
if (!APPLY) {
console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
process.exit(0);
}
const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
const delUser = db.prepare('DELETE FROM users WHERE id=?');
let deleted = 0;
db.exec('BEGIN');
try {
for (const u of remove) {
delAuth.run(u.id); // clear active sessions (FK) — also logs them out
if (delRefresh) delRefresh.run(u.id);
delUser.run(u.id);
deleted++;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
console.error('FAILED — rolled back, no changes applied:', e.message);
process.exit(1);
}
console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);
+54
View File
@@ -0,0 +1,54 @@
// Outbound webhook delivery. emit(event, tenantId, payload) fans the event out to every
// active per-tenant subscription registered for that event, plus the legacy global
// BIZGAZE_WEBHOOK_URL (back-compat). Each delivery is HMAC-signed and retried on failure.
//
// NOTE (roadmap): retries are in-memory/best-effort. For guaranteed delivery this should
// move to a persistent queue when the app scales to multiple instances (see ARCHITECTURE.md).
const R = require('./repos');
const crypto = require('crypto');
const EVENTS = ['session.started', 'session.ended'];
function sign(secret, body) {
return crypto.createHmac('sha256', secret || '').update(body).digest('base64url');
}
const RETRY_DELAYS = [2000, 10000, 30000]; // after the first attempt
function deliver(url, secret, body, onDone) {
let attempt = 0;
const go = async () => {
attempt++;
let ok = false, status = 0, err = null;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-BizGaze-Signature': sign(secret, body), 'X-BizGaze-Event': (() => { try { return JSON.parse(body).event; } catch { return ''; } })() },
body,
signal: AbortSignal.timeout(10000),
});
status = res.status; ok = res.ok;
} catch (e) { err = (e && e.message) || 'delivery failed'; }
if (ok || attempt > RETRY_DELAYS.length) { if (onDone) onDone({ ok, status, err }); return; }
setTimeout(go, RETRY_DELAYS[attempt - 1]);
};
go();
}
function emit(event, tenantId, payload) {
const body = JSON.stringify({ event, ...payload });
// Per-tenant subscriptions
try {
for (const h of R.webhooks.activeForTenant(tenantId)) {
const subs = String(h.events || '').split(',').map((s) => s.trim());
if (subs.includes('*') || subs.includes(event)) {
deliver(h.url, h.secret, body, (r) => { try { R.webhooks.setStatus(h.id, r.ok ? 1 : 0, r.err || ('HTTP ' + r.status)); } catch (_) {} });
}
}
} catch (_) {}
// Legacy global webhook (back-compat): session.ended → BIZGAZE_WEBHOOK_URL, signed with SSO_SECRET.
if (event === 'session.ended' && process.env.BIZGAZE_WEBHOOK_URL) {
deliver(process.env.BIZGAZE_WEBHOOK_URL, process.env.SSO_SECRET || '', body);
}
}
module.exports = { emit, sign, EVENTS };