@dszp/netsapiens-lib 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -172,9 +172,17 @@ against one of them needs to know which inventory items are actually its own. `a
172
172
  answers that: it's pure (no account knowledge, just the snapshot) and labels every extension, number,
173
173
  address and SMS number either `own-site` (the item's own site matches), `via-user:<ext>` /
174
174
  `via-users:<exts>` (it's reachable only through another item that has a site), or an
175
- `unattributed:<reason>` — `no-site`, `routed-to:<x>`, `shared-across:<sites>`, `unreferenced`, or
175
+ `unattributed:<reason>` — `no-site`, `routed-to:<x>`, `unreferenced`, or
176
176
  `sms-user-unknown` when a domain-level SMS number can't be matched to a user because the snapshot was
177
- never fetched with `includeUserSmsNumbers`. Filter `listDomainInventory(snapshot)`'s items by that
177
+ never fetched with `includeUserSmsNumbers`.
178
+
179
+ Each verdict carries **`sites: string[]`** as well as `site`. They agree wherever there is one site,
180
+ and only an **E911 address** can carry more than one: an address is a fact about a place, and users on
181
+ four sites can all reference it, so `sites` names every one of them while `site` stays `null`. A
182
+ consumer splitting a domain between billing accounts should read `sites` — placing such an address on
183
+ exactly one account leaves every other referencing account's E911 line short.
184
+
185
+ Filter `listDomainInventory(snapshot)`'s items by that
178
186
  attribution to scope a domain down to one site, then run `countInventoryDetail(detail)` over what's left
179
187
  to get counts that agree with what you kept, rather than recomputing `countDomainInventory` against the
180
188
  whole domain and hoping the numbers happen to match.
@@ -1,7 +1,19 @@
1
1
  import type { Snapshot } from './model.js';
2
2
  export interface ItemAttribution {
3
- /** The site this item belongs to; `null` when it cannot be placed. */
3
+ /**
4
+ * The single site this item belongs to; `null` when there is not exactly one — nothing placed it, or
5
+ * (addresses only) several did. Read {@link sites} to tell those two apart.
6
+ */
4
7
  site: string | null;
8
+ /**
9
+ * EVERY site this item belongs to, unique and sorted. One entry wherever {@link site} is set, none
10
+ * where nothing placed it, and — for an address referenced from several sites — one per site.
11
+ *
12
+ * The wider field, and the one a consumer splitting a domain between accounts should read: only an
13
+ * ADDRESS can carry more than one, because only an address is a fact about a place rather than about
14
+ * a user, a number or a route.
15
+ */
16
+ sites: string[];
5
17
  /** `own-site` | `via-user:<ext>` | `via-users:<ext,ext>` | `unattributed:<reason>`. */
6
18
  how: string;
7
19
  }
@@ -1 +1 @@
1
- {"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAO,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B,sEAAsE;IACtE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,iBAAiB;IAAG,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;CAAE;AAI7E,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,GAAG,iBAAiB,CAyD9E"}
1
+ {"version":3,"file":"attribution.d.ts","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAO,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB;;;;;;;OAOG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,iBAAiB;IAAG,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;CAAE;AAI7E,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,GAAG,iBAAiB,CA8D9E"}
@@ -18,9 +18,12 @@
18
18
  * a queue at the North site is a North number. Only a site-less system user falls back to
19
19
  * `routed-to:<that user's service-code>`; when the destination names nobody, `routed-to:<application>`;
20
20
  * a site-less real user is `no-site`.
21
- * - An address: the one site every REAL extension referencing it sits on (`via-users:<exts>`);
22
- * `shared-across:<sites>` when they sit on more than one; `unreferenced` when none does;
23
- * `no-site` when the referencing users have no site.
21
+ * - An address: EVERY site a REAL extension referencing it sits on (`via-users:<exts>`), in `sites`.
22
+ * An address is a fact about a PLACE, and users on four sites can legitimately reference one — so
23
+ * the multi-site case is not a failure to attribute, it is the answer. `site` is the single site
24
+ * when there is exactly one and `null` otherwise, so a consumer that can only hold one still reads
25
+ * the unambiguous case correctly. `unreferenced` when no real extension names it; `no-site` when the
26
+ * ones that do have no site.
24
27
  * - An SMS number: the site of the user whose per-user list carries it (`via-user:<ext>`), or
25
28
  * `sms-user-unknown` when no per-user list does — including when the per-user read was never
26
29
  * made. Never guessed from the domain-level list, which does not say.
@@ -32,7 +35,7 @@
32
35
  * invariant is this library's own, and this is the one place allowed to lean on it.
33
36
  */
34
37
  import { isSystemUser, listDomainInventory, str, usersByExt } from './inventory.js';
35
- const none = (reason) => ({ site: null, how: `unattributed:${reason}` });
38
+ const none = (reason) => ({ site: null, sites: [], how: `unattributed:${reason}` });
36
39
  export function attributeDomainInventory(snapshot) {
37
40
  const users = Array.isArray(snapshot.users) ? snapshot.users : [];
38
41
  const phonenumbers = Array.isArray(snapshot.phonenumbers) ? snapshot.phonenumbers : [];
@@ -42,9 +45,11 @@ export function attributeDomainInventory(snapshot) {
42
45
  const d = listDomainInventory(snapshot);
43
46
  const items = {};
44
47
  const userByExt = usersByExt(users);
48
+ /** The single-site verdict, with `sites` kept in step so the two fields can never disagree. */
49
+ const one = (site, how) => ({ site, sites: [site], how });
45
50
  // Extensions: the detail already carries `site`, so no record is needed here.
46
51
  for (const x of d.extensions)
47
- items[x.key] = x.site ? { site: x.site, how: 'own-site' } : none('no-site');
52
+ items[x.key] = x.site ? one(x.site, 'own-site') : none('no-site');
48
53
  // Numbers, by index against `phonenumbers`.
49
54
  for (let i = 0; i < d.dids.length; i++) {
50
55
  const key = d.dids[i].key, p = phonenumbers[i] ?? {};
@@ -60,7 +65,7 @@ export function attributeDomainInventory(snapshot) {
60
65
  items[key] = isSystemUser(u) ? none(`routed-to:${str(u['service-code'])}`) : none('no-site');
61
66
  continue;
62
67
  }
63
- items[key] = { site, how: `via-user:${dest}` };
68
+ items[key] = one(site, `via-user:${dest}`);
64
69
  }
65
70
  // Addresses, by index against `addresses`; referenced by REAL extensions only.
66
71
  const refs = new Map();
@@ -82,12 +87,15 @@ export function attributeDomainInventory(snapshot) {
82
87
  continue;
83
88
  }
84
89
  const sites = [...new Set(who.map((u) => str(u.site)).filter(Boolean))].sort();
85
- if (sites.length === 0)
90
+ if (sites.length === 0) {
86
91
  items[key] = none('no-site');
87
- else if (sites.length === 1)
88
- items[key] = { site: sites[0], how: `via-users:${who.map((u) => str(u.user)).filter(Boolean).sort().join(',')}` };
89
- else
90
- items[key] = none(`shared-across:${sites.join(',')}`);
92
+ continue;
93
+ }
94
+ // SEVERAL SITES IS AN ANSWER, not a failure. The referencing users name every place this address
95
+ // is used, and a consumer billing per site needs all of them; `site` still answers only the
96
+ // unambiguous case, which is what keeps a one-site consumer correct without reading `sites`.
97
+ const how = `via-users:${who.map((u) => str(u.user)).filter(Boolean).sort().join(',')}`;
98
+ items[key] = sites.length === 1 ? one(sites[0], how) : { site: null, sites, how };
91
99
  }
92
100
  // SMS numbers, by index against `smsnumbers`, joined to a user through the per-user lists.
93
101
  const extBySms = new Map();
@@ -107,7 +115,7 @@ export function attributeDomainInventory(snapshot) {
107
115
  continue;
108
116
  }
109
117
  const site = str(u.site);
110
- items[key] = site ? { site, how: `via-user:${ext}` } : none('no-site');
118
+ items[key] = site ? one(site, `via-user:${ext}`) : none('no-site');
111
119
  }
112
120
  return { items };
113
121
  }
@@ -1 +1 @@
1
- {"version":3,"file":"attribution.js","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"attribution.js","sourceRoot":"","sources":["../src/attribution.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dszp/netsapiens-lib",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Portable, Node-free NetSapiens toolkit: split read/write API clients, JWT (ns_t) validation, and a snapshot -> FlowGraph -> Mermaid call-flow resolver/renderer. Runs unchanged in a Cloudflare Worker, Node, or the browser.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,9 +49,30 @@ ok(a['did:13175550102']!.site === null && a['did:13175550102']!.how === 'unattri
49
49
  ok(a['did:13175550103']!.site === null && a['did:13175550103']!.how === 'unattributed:no-site', 'a number routed to a site-less user is no-site, not routed-to');
50
50
  ok(a['did:13175550104']!.site === null && a['did:13175550104']!.how === 'unattributed:routed-to:to-user', 'a number whose destination user does not exist falls back to the application name');
51
51
 
52
- ok(a['addr:a-1']!.site === null && a['addr:a-1']!.how === 'unattributed:shared-across:North,South', 'an address referenced from two sites is shared, sites sorted');
53
- ok(a['addr:a-2']!.site === 'South' && a['addr:a-2']!.how === 'via-users:102', 'an address referenced from one site follows it and names the users');
54
- ok(a['addr:a-3']!.site === null && a['addr:a-3']!.how === 'unattributed:unreferenced', 'an address nobody references is unreferenced');
52
+ ok(a['addr:a-1']!.site === null && JSON.stringify(a['addr:a-1']!.sites) === JSON.stringify(['North', 'South']) && a['addr:a-1']!.how === 'via-users:100,101',
53
+ 'an address referenced from two sites carries BOTH, sorted, with no single site and no shared-across reason');
54
+ ok(a['addr:a-2']!.site === 'South' && JSON.stringify(a['addr:a-2']!.sites) === JSON.stringify(['South']) && a['addr:a-2']!.how === 'via-users:102', 'an address referenced from one site follows it and names the users');
55
+ ok(a['addr:a-3']!.site === null && a['addr:a-3']!.sites.length === 0 && a['addr:a-3']!.how === 'unattributed:unreferenced', 'an address nobody references is unreferenced');
56
+
57
+ // `sites` is the list form of `site` for every kind that has exactly one, so a consumer can read the
58
+ // one field and never branch on the kind.
59
+ ok(JSON.stringify(a['ext:100']!.sites) === JSON.stringify(['North']), 'an extension carries its own site as a one-entry list');
60
+ ok(a['ext:103']!.sites.length === 0, 'and a site-less one carries an empty list');
61
+ ok(JSON.stringify(a['did:13175550100']!.sites) === JSON.stringify(['North']), 'a number carries the site it inherited');
62
+ ok(a['did:13175550102']!.sites.length === 0, 'and an unattributed number carries none');
63
+ ok(JSON.stringify(a['sms:13175550100']!.sites) === JSON.stringify(['North']), 'an SMS number carries the site of the user it is enabled on');
64
+ ok(a['sms:13175550199']!.sites.length === 0, 'and an unknown one carries none');
65
+ ok(Object.values(a).every((v) => JSON.stringify(v.sites) === JSON.stringify([...new Set(v.sites)].sort())), 'every sites list is unique and sorted');
66
+ ok(Object.values(a).every((v) => (v.site === null ? true : v.sites.length === 1 && v.sites[0] === v.site)), 'a single site is exactly the one-entry list, and never disagrees with it');
67
+
68
+ // An address whose referencing users have no site at all: still unattributed, and the reason says why.
69
+ const noSiteAddr = attributeDomainInventory({
70
+ ...snap,
71
+ users: [{ user: '104', site: '', 'service-code': '', 'emergency-address-id': 'a-4' }],
72
+ addresses: [{ 'emergency-address-id': 'a-4' }],
73
+ }).items;
74
+ ok(noSiteAddr['addr:a-4']!.how === 'unattributed:no-site' && noSiteAddr['addr:a-4']!.sites.length === 0,
75
+ 'an address whose referencing users have no site is no-site, with no sites to name');
55
76
 
56
77
  ok(a['sms:13175550100']!.site === 'North' && a['sms:13175550100']!.how === 'via-user:100', 'an SMS number follows the user it is enabled on');
57
78
  ok(a['sms:13175550102']!.site === null && a['sms:13175550102']!.how === 'unattributed:no-site', 'an SMS number on a site-less user is no-site');
@@ -18,9 +18,12 @@
18
18
  * a queue at the North site is a North number. Only a site-less system user falls back to
19
19
  * `routed-to:<that user's service-code>`; when the destination names nobody, `routed-to:<application>`;
20
20
  * a site-less real user is `no-site`.
21
- * - An address: the one site every REAL extension referencing it sits on (`via-users:<exts>`);
22
- * `shared-across:<sites>` when they sit on more than one; `unreferenced` when none does;
23
- * `no-site` when the referencing users have no site.
21
+ * - An address: EVERY site a REAL extension referencing it sits on (`via-users:<exts>`), in `sites`.
22
+ * An address is a fact about a PLACE, and users on four sites can legitimately reference one — so
23
+ * the multi-site case is not a failure to attribute, it is the answer. `site` is the single site
24
+ * when there is exactly one and `null` otherwise, so a consumer that can only hold one still reads
25
+ * the unambiguous case correctly. `unreferenced` when no real extension names it; `no-site` when the
26
+ * ones that do have no site.
24
27
  * - An SMS number: the site of the user whose per-user list carries it (`via-user:<ext>`), or
25
28
  * `sms-user-unknown` when no per-user list does — including when the per-user read was never
26
29
  * made. Never guessed from the domain-level list, which does not say.
@@ -35,14 +38,26 @@ import { isSystemUser, listDomainInventory, str, usersByExt } from './inventory.
35
38
  import type { Rec, Snapshot } from './model.js';
36
39
 
37
40
  export interface ItemAttribution {
38
- /** The site this item belongs to; `null` when it cannot be placed. */
41
+ /**
42
+ * The single site this item belongs to; `null` when there is not exactly one — nothing placed it, or
43
+ * (addresses only) several did. Read {@link sites} to tell those two apart.
44
+ */
39
45
  site: string | null;
46
+ /**
47
+ * EVERY site this item belongs to, unique and sorted. One entry wherever {@link site} is set, none
48
+ * where nothing placed it, and — for an address referenced from several sites — one per site.
49
+ *
50
+ * The wider field, and the one a consumer splitting a domain between accounts should read: only an
51
+ * ADDRESS can carry more than one, because only an address is a fact about a place rather than about
52
+ * a user, a number or a route.
53
+ */
54
+ sites: string[];
40
55
  /** `own-site` | `via-user:<ext>` | `via-users:<ext,ext>` | `unattributed:<reason>`. */
41
56
  how: string;
42
57
  }
43
58
  export interface DomainAttribution { items: Record<string, ItemAttribution> }
44
59
 
45
- const none = (reason: string): ItemAttribution => ({ site: null, how: `unattributed:${reason}` });
60
+ const none = (reason: string): ItemAttribution => ({ site: null, sites: [], how: `unattributed:${reason}` });
46
61
 
47
62
  export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution {
48
63
  const users: Rec[] = Array.isArray(snapshot.users) ? snapshot.users : [];
@@ -54,9 +69,11 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
54
69
  const items: Record<string, ItemAttribution> = {};
55
70
 
56
71
  const userByExt = usersByExt(users);
72
+ /** The single-site verdict, with `sites` kept in step so the two fields can never disagree. */
73
+ const one = (site: string, how: string): ItemAttribution => ({ site, sites: [site], how });
57
74
 
58
75
  // Extensions: the detail already carries `site`, so no record is needed here.
59
- for (const x of d.extensions) items[x.key] = x.site ? { site: x.site, how: 'own-site' } : none('no-site');
76
+ for (const x of d.extensions) items[x.key] = x.site ? one(x.site, 'own-site') : none('no-site');
60
77
 
61
78
  // Numbers, by index against `phonenumbers`.
62
79
  for (let i = 0; i < d.dids.length; i++) {
@@ -67,7 +84,7 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
67
84
  if (!u) { items[key] = none(`routed-to:${app}`); continue; }
68
85
  const site = str(u.site);
69
86
  if (!site) { items[key] = isSystemUser(u) ? none(`routed-to:${str(u['service-code'])}`) : none('no-site'); continue; }
70
- items[key] = { site, how: `via-user:${dest}` };
87
+ items[key] = one(site, `via-user:${dest}`);
71
88
  }
72
89
 
73
90
  // Addresses, by index against `addresses`; referenced by REAL extensions only.
@@ -83,9 +100,12 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
83
100
  const who = id ? refs.get(id) ?? [] : [];
84
101
  if (!who.length) { items[key] = none('unreferenced'); continue; }
85
102
  const sites = [...new Set(who.map((u) => str(u.site)).filter(Boolean))].sort();
86
- if (sites.length === 0) items[key] = none('no-site');
87
- else if (sites.length === 1) items[key] = { site: sites[0]!, how: `via-users:${who.map((u) => str(u.user)).filter(Boolean).sort().join(',')}` };
88
- else items[key] = none(`shared-across:${sites.join(',')}`);
103
+ if (sites.length === 0) { items[key] = none('no-site'); continue; }
104
+ // SEVERAL SITES IS AN ANSWER, not a failure. The referencing users name every place this address
105
+ // is used, and a consumer billing per site needs all of them; `site` still answers only the
106
+ // unambiguous case, which is what keeps a one-site consumer correct without reading `sites`.
107
+ const how = `via-users:${who.map((u) => str(u.user)).filter(Boolean).sort().join(',')}`;
108
+ items[key] = sites.length === 1 ? one(sites[0]!, how) : { site: null, sites, how };
89
109
  }
90
110
 
91
111
  // SMS numbers, by index against `smsnumbers`, joined to a user through the per-user lists.
@@ -97,7 +117,7 @@ export function attributeDomainInventory(snapshot: Snapshot): DomainAttribution
97
117
  const u = ext ? userByExt.get(ext) : undefined;
98
118
  if (!u) { items[key] = none('sms-user-unknown'); continue; }
99
119
  const site = str(u.site);
100
- items[key] = site ? { site, how: `via-user:${ext}` } : none('no-site');
120
+ items[key] = site ? one(site, `via-user:${ext}`) : none('no-site');
101
121
  }
102
122
 
103
123
  return { items };