src/org-identity.js
Rama master · réplica de lectura del repositorio original.
/**
* Org/team identity for pool-level arithmetic.
*
* Some pool numbers are ORGANIZATION-scoped, not account-scoped: the on-demand
* `$` balance on Teams plans is read from the same team for every member, so
* summing it once per account multiplies the pool total by the team size (the
* 3.9.53 KPI did exactly that and said so in a code comment). To add balances
* up honestly the dashboard needs one identity per billing owner.
*
* That identity is shared with the ACU reader (`dashboard/acu-usage.js`), which
* already resolves the real app.devin.ai org per account. Once a panel read has
* resolved an org, `rememberAccountOrg` records the mapping and the learned
* `orgref-…` handle is authoritative. Before that (or for a pool with no Devin
* session token) the team id carried by GetUserStatus is the next-best
* identity. Nothing is guessed: an account with no known org/team is its own
* billing owner.
*
* The handle is the same non-reversible `orgref-<hash>` the ACU panel shows, so
* a raw org/team id never leaves the server.
*/
import { createHash } from 'node:crypto';
/** Cap on remembered account→org mappings (a pool is far smaller than this). */
const MAX_LEARNED = 4096;
/** accountId → orgRef, filled by the ACU reader's membership resolution. */
const learned = new Map();
/**
* Stable, non-reversible handle for an org/team id. Kept byte-identical to the
* ACU panel's handle so the two views name the same org the same way.
*/
export function orgRefOf(orgId) {
return `orgref-${createHash('sha256').update(String(orgId || '')).digest('hex').slice(0, 12)}`;
}
/** Record the org an account was resolved into (called by the ACU reader). */
export function rememberAccountOrg(accountId, orgId) {
const id = String(accountId || '');
if (!id || !orgId) return;
if (!learned.has(id) && learned.size >= MAX_LEARNED) {
const oldest = learned.keys().next().value;
if (oldest !== undefined) learned.delete(oldest);
}
learned.set(id, orgRefOf(orgId));
}
/** The learned org handle for an account, or '' when it was never resolved. */
export function learnedOrgRefOf(accountId) {
return learned.get(String(accountId || '')) || '';
}
/** Test seam: drop every learned mapping. */
export function __resetOrgIdentityForTest() {
learned.clear();
}
/**
* Billing-owner key for one account.
*
* Order: ACU-resolved org → GetUserStatus team id → the account itself. The
* first two are namespaced apart (`org:` vs `team:`) because the Devin org id
* and the Windsurf team id live in different id spaces; merging them on the
* string alone would invent a shared balance that was never observed.
*
* @returns {{key: string, source: 'org'|'team'|'account'}}
*/
export function balanceGroupOf(account) {
if (!account || typeof account !== 'object') return { key: 'account:', source: 'account' };
const id = String(account.id || '');
const known = learnedOrgRefOf(id);
if (known) return { key: `org:${known}`, source: 'org' };
const teamId = String(account?.userStatus?.teamId || '').trim();
if (teamId) return { key: `team:${orgRefOf(teamId)}`, source: 'team' };
return { key: `account:${id}`, source: 'account' };
}