import type { ServerWebSocket } from 'bun'; import { randomUUID } from 'node:crypto'; import { listProvisionableUsers } from 'officerdb'; import { discoverPlugins } from './discover'; import { socketForSidecar } from '../sidecar-registry'; // Telling a plugin who the platform's users are, so it can create matching accounts upstream. // // ── Push, not request ── // // A sidecar has no authenticated way to ask. `/api/sidecar/register` upgrades with `userId: 0` and no // token, so anything reaching the port can register — which means a REQUEST for this list could not be // authorised, only answered. A push inverts that: the platform decides who gets it, from the manifest the // owner reviewed at install, and a caller that registers under a name with no such manifest gets nothing. // // The list is deliberately modest — id, email, username, name, role, owner excluded. See // `listProvisionableUsers`, which is a hand-written projection rather than `select *` so that adding a // column to `users` cannot silently add it to what plugins receive. // // ── Sent again when it changes ── // // The registration copy is a snapshot, and an account created an hour later would not be in it. So the // same message is re-sent on change; a sidecar therefore treats `users:list` as the current truth and // replaces what it held, rather than merging. /** Does this plugin's manifest declare user provisioning? Read from disk, never from the socket. */ async function declaresUserProvisioning(appName: string): Promise { try { const { plugins } = await discoverPlugins(); return plugins.some((p) => p.appName === appName && p.manifest.userProvisioning === true); } catch { return false; } } /** * Send the account list to one sidecar, if its plugin is entitled to it. * * Never throws: this runs detached from the registration handler, and a failure here must not take down * a socket that is otherwise fine. A plugin that does not receive the list simply has nothing to * provision, which is the same state it is in before the platform has any users. */ export async function sendProvisionableUsers(ws: ServerWebSocket, appName: string): Promise { try { if (!(await declaresUserProvisioning(appName))) return; const users = await listProvisionableUsers(); ws.send(JSON.stringify({ type: 'users:list', id: randomUUID(), users })); } catch (error) { console.error( `[user-provisioning] could not send the user list to ${appName}:`, error instanceof Error ? error.message : error, ); } } /** * Re-send the list to every plugin entitled to it. * * Called wherever an account appears or disappears. Fire-and-forget on purpose: creating a user must not * fail because a plugin's sidecar is down, and the next registration re-sends anyway. */ export async function broadcastProvisionableUsers(): Promise { try { const { plugins } = await discoverPlugins(); const entitled = plugins.filter((p) => p.manifest.userProvisioning === true); if (!entitled.length) return; // Read ONCE for all of them: two plugins must not be told different things about the same moment. const users = await listProvisionableUsers(); const payload = JSON.stringify({ type: 'users:list', id: randomUUID(), users }); for (const plugin of entitled) { const ws = socketForSidecar(plugin.appName); // Not an error. A sidecar that is down gets the list when it next registers, which is the same // path a fresh install takes. if (ws) ws.send(payload); } } catch (error) { console.error('[user-provisioning] broadcast failed:', error instanceof Error ? error.message : error); } }