@diagc/core 0.1.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/LICENSE +709 -0
- package/README.md +27 -0
- package/dist/builder.d.ts +381 -0
- package/dist/builder.js +590 -0
- package/dist/children.d.ts +21 -0
- package/dist/children.js +45 -0
- package/dist/commands.d.ts +219 -0
- package/dist/commands.js +474 -0
- package/dist/compose.d.ts +19 -0
- package/dist/compose.js +246 -0
- package/dist/drawings.d.ts +13 -0
- package/dist/drawings.js +36 -0
- package/dist/eject.d.ts +20 -0
- package/dist/eject.js +260 -0
- package/dist/fishbone.d.ts +66 -0
- package/dist/fishbone.js +95 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.js +159 -0
- package/dist/guards.d.ts +10 -0
- package/dist/guards.js +98 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +45 -0
- package/dist/labels.d.ts +5 -0
- package/dist/labels.js +10 -0
- package/dist/layout-defaults.d.ts +20 -0
- package/dist/layout-defaults.js +20 -0
- package/dist/mutate.d.ts +106 -0
- package/dist/mutate.js +547 -0
- package/dist/second-order.d.ts +39 -0
- package/dist/second-order.js +86 -0
- package/dist/text.d.ts +5 -0
- package/dist/text.js +25 -0
- package/dist/threat-model.d.ts +88 -0
- package/dist/threat-model.js +188 -0
- package/dist/types.d.ts +395 -0
- package/dist/types.js +27 -0
- package/dist/util.d.ts +11 -0
- package/dist/util.js +13 -0
- package/dist/validate.d.ts +19 -0
- package/dist/validate.js +736 -0
- package/dist/view/compile.d.ts +29 -0
- package/dist/view/compile.js +78 -0
- package/dist/view/edges.d.ts +4 -0
- package/dist/view/edges.js +118 -0
- package/dist/view/hierarchy.d.ts +41 -0
- package/dist/view/hierarchy.js +103 -0
- package/dist/view/layers.d.ts +7 -0
- package/dist/view/layers.js +17 -0
- package/dist/view/lod.d.ts +15 -0
- package/dist/view/lod.js +17 -0
- package/dist/view/scope.d.ts +23 -0
- package/dist/view/scope.js +106 -0
- package/dist/view/size.d.ts +8 -0
- package/dist/view/size.js +34 -0
- package/dist/view/tree.d.ts +12 -0
- package/dist/view/tree.js +147 -0
- package/dist/view/types.d.ts +68 -0
- package/dist/view/types.js +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** The notation id a plane (or the model) declares to be drawn as a consequence tree. */
|
|
2
|
+
export const SECOND_ORDER_NOTATION = 'second-order';
|
|
3
|
+
export const SO_DECISION_TYPE = 'so-decision';
|
|
4
|
+
/** Valence is a TYPE VARIANT, not a field: a registry and palette entry, no
|
|
5
|
+
* schema change — the same trick C4 uses for its `-external` stencils. */
|
|
6
|
+
export const SO_CONSEQUENCE_TYPES = ['so-consequence-positive', 'so-consequence-negative', 'so-consequence-neutral'];
|
|
7
|
+
/** The kind the builder and the studio create. The derivation below does NOT
|
|
8
|
+
* filter by it (see consequenceOrders). */
|
|
9
|
+
export const SO_LEADS_TO_KIND = 'leads-to';
|
|
10
|
+
const TYPE_OF = {
|
|
11
|
+
'+': 'so-consequence-positive',
|
|
12
|
+
'-': 'so-consequence-negative',
|
|
13
|
+
'0': 'so-consequence-neutral',
|
|
14
|
+
};
|
|
15
|
+
export const consequenceTypeOf = (v) => TYPE_OF[v];
|
|
16
|
+
export function valenceOf(type) {
|
|
17
|
+
return Object.keys(TYPE_OF).find((v) => TYPE_OF[v] === type);
|
|
18
|
+
}
|
|
19
|
+
export const isSecondOrderNode = (n) => n.type === SO_DECISION_TYPE || valenceOf(n.type) !== undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Which band each decision and consequence belongs to. This is the ONE place
|
|
22
|
+
* that answers it, so the layout's partitions, the band overlay, the studio
|
|
23
|
+
* panel and validation cannot disagree.
|
|
24
|
+
*
|
|
25
|
+
* The order is the LONGEST path from a decision: every cause then sits in an
|
|
26
|
+
* earlier band than its effect, and no arrow ever runs inside a band — which is
|
|
27
|
+
* also exactly what lets elk take the orders as layer partitions.
|
|
28
|
+
*
|
|
29
|
+
* Reads the MODEL's relations, never drawn edges: toggling a layer must not
|
|
30
|
+
* move a box. Any kind of relation between two second-order nodes counts, so
|
|
31
|
+
* restyling an arrow can never drop a node out of its band. Never throws.
|
|
32
|
+
*/
|
|
33
|
+
export function consequenceOrders(model) {
|
|
34
|
+
const nodes = model.nodes.filter(isSecondOrderNode);
|
|
35
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
36
|
+
const next = new Map(nodes.map((n) => [n.id, []]));
|
|
37
|
+
const prev = new Map(nodes.map((n) => [n.id, []]));
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const r of model.relations) {
|
|
40
|
+
if (r.from === r.to || !ids.has(r.from) || !ids.has(r.to))
|
|
41
|
+
continue;
|
|
42
|
+
const pair = `${r.from}\u0000${r.to}`;
|
|
43
|
+
if (seen.has(pair))
|
|
44
|
+
continue;
|
|
45
|
+
seen.add(pair);
|
|
46
|
+
next.get(r.from).push(r.to);
|
|
47
|
+
prev.get(r.to).push(r.from);
|
|
48
|
+
}
|
|
49
|
+
// Kahn: whatever is never released sits on a loop or behind one.
|
|
50
|
+
const waiting = new Map(nodes.map((n) => [n.id, prev.get(n.id).length]));
|
|
51
|
+
const topo = nodes.filter((n) => waiting.get(n.id) === 0).map((n) => n.id);
|
|
52
|
+
for (let i = 0; i < topo.length; i++) {
|
|
53
|
+
for (const to of next.get(topo[i])) {
|
|
54
|
+
const left = waiting.get(to) - 1;
|
|
55
|
+
waiting.set(to, left);
|
|
56
|
+
if (left === 0)
|
|
57
|
+
topo.push(to);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (topo.length < nodes.length) {
|
|
61
|
+
// Peel what merely hangs OFF the loop (nothing stuck follows from it), so
|
|
62
|
+
// the report names the loop itself.
|
|
63
|
+
const released = new Set(topo);
|
|
64
|
+
const stuck = new Set(nodes.map((n) => n.id).filter((id) => !released.has(id)));
|
|
65
|
+
for (let peeled = true; peeled;) {
|
|
66
|
+
peeled = false;
|
|
67
|
+
for (const id of stuck) {
|
|
68
|
+
if (next.get(id).some((to) => stuck.has(to)))
|
|
69
|
+
continue;
|
|
70
|
+
stuck.delete(id);
|
|
71
|
+
peeled = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { orders: new Map(), cycle: nodes.map((n) => n.id).filter((id) => stuck.has(id)), unreachable: [] };
|
|
75
|
+
}
|
|
76
|
+
const typeOf = new Map(nodes.map((n) => [n.id, n.type]));
|
|
77
|
+
const orders = new Map();
|
|
78
|
+
for (const id of topo) {
|
|
79
|
+
const causes = prev.get(id).map((p) => orders.get(p)).filter((o) => o !== undefined);
|
|
80
|
+
if (causes.length > 0)
|
|
81
|
+
orders.set(id, Math.max(...causes) + 1);
|
|
82
|
+
else if (typeOf.get(id) === SO_DECISION_TYPE)
|
|
83
|
+
orders.set(id, 0);
|
|
84
|
+
}
|
|
85
|
+
return { orders, unreachable: nodes.map((n) => n.id).filter((id) => !orders.has(id)) };
|
|
86
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TextRun } from './types.js';
|
|
2
|
+
export declare function runsToPlainText(runs: TextRun[]): string;
|
|
3
|
+
/** Canonical form: drop empty runs, coerce falsey marks away, merge adjacent
|
|
4
|
+
* runs whose marks match. */
|
|
5
|
+
export declare function normalizeRuns(runs: TextRun[]): TextRun[];
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function runsToPlainText(runs) {
|
|
2
|
+
return runs.map((r) => r.text).join('');
|
|
3
|
+
}
|
|
4
|
+
/** Canonical form: drop empty runs, coerce falsey marks away, merge adjacent
|
|
5
|
+
* runs whose marks match. */
|
|
6
|
+
export function normalizeRuns(runs) {
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const r of runs) {
|
|
9
|
+
if (r.text === '')
|
|
10
|
+
continue;
|
|
11
|
+
const run = { text: r.text };
|
|
12
|
+
if (r.bold === true)
|
|
13
|
+
run.bold = true;
|
|
14
|
+
if (r.italic === true)
|
|
15
|
+
run.italic = true;
|
|
16
|
+
const prev = out[out.length - 1];
|
|
17
|
+
if (prev !== undefined && Boolean(prev.bold) === Boolean(run.bold) && Boolean(prev.italic) === Boolean(run.italic)) {
|
|
18
|
+
prev.text += run.text;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
out.push(run);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type DiagramModel, type DiagramNode, type LayoutOverlay, type StrideCategory, type Threat, type ThreatStatus } from './types.js';
|
|
2
|
+
/** The notation id a plane (or the model) declares to be drawn as a STRIDE
|
|
3
|
+
* data-flow diagram. */
|
|
4
|
+
export declare const TM_NOTATION: "threat-model";
|
|
5
|
+
export declare const TM_ENTITY_TYPE: "tm-entity";
|
|
6
|
+
export declare const TM_PROCESS_TYPE: "tm-process";
|
|
7
|
+
export declare const TM_STORE_TYPE: "tm-store";
|
|
8
|
+
/** A trust boundary is a CONTAINER, never a box with arrows: "this flow crosses
|
|
9
|
+
* a boundary" is then derived from containment (see crossings) instead of being
|
|
10
|
+
* authored, and cannot disagree with the drawing. */
|
|
11
|
+
export declare const TM_BOUNDARY_TYPE: "tm-boundary";
|
|
12
|
+
export declare const TM_FLOW_KIND: "data-flow";
|
|
13
|
+
export declare const TM_TYPES: ReadonlySet<string>;
|
|
14
|
+
export declare const isThreatModelNode: (n: DiagramNode) => boolean;
|
|
15
|
+
export declare const STRIDE_NAMES: Record<StrideCategory, string>;
|
|
16
|
+
/** The title a threat gets when it is added from the canvas, before anyone has
|
|
17
|
+
* typed one. It is a real title rather than `''` because a threat with an empty
|
|
18
|
+
* title fails validation (`threat-title`) — and the studio autosaves on a timer,
|
|
19
|
+
* so an empty one would reach the save handler as a 400 while the user is still
|
|
20
|
+
* typing. Lives in core so every host seeds the same word, and so "was this ever
|
|
21
|
+
* titled?" is one comparison rather than a per-host convention. */
|
|
22
|
+
export declare const NEW_THREAT_TITLE = "New threat";
|
|
23
|
+
/** which element a threat command or register row refers to */
|
|
24
|
+
export type ThreatTarget = {
|
|
25
|
+
node: string;
|
|
26
|
+
} | {
|
|
27
|
+
relation: string;
|
|
28
|
+
};
|
|
29
|
+
/** The overlay key a threat target is filed under (`LayoutOverlay.notes`). Node
|
|
30
|
+
* and relation ids are separate namespaces — nothing stops a node and a relation
|
|
31
|
+
* sharing an id — so one flat map needs the prefix. */
|
|
32
|
+
export declare const threatTargetKey: (t: ThreatTarget) => string;
|
|
33
|
+
/** The element's threat list — `[]` when it carries none, undefined when there
|
|
34
|
+
* is no such element. The two are different answers: a note is drawn for the
|
|
35
|
+
* first case's element and the second is a dangling reference. */
|
|
36
|
+
export declare function threatsOf(m: DiagramModel, t: ThreatTarget): readonly Threat[] | undefined;
|
|
37
|
+
/** First free `t<n>`. Ids are scoped to their element, so a removed `t2` is
|
|
38
|
+
* handed out again rather than the counter climbing forever. */
|
|
39
|
+
export declare function nextThreatId(threats: readonly Threat[]): string;
|
|
40
|
+
/** the boundaries a relation's ends sit in; an absent side is "outside every boundary" */
|
|
41
|
+
export interface Crossing {
|
|
42
|
+
from?: string;
|
|
43
|
+
to?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface ThreatRow {
|
|
46
|
+
target: ThreatTarget;
|
|
47
|
+
/** the element's display name — a flow reads `from → to (label)` */
|
|
48
|
+
name: string;
|
|
49
|
+
threat: Threat;
|
|
50
|
+
}
|
|
51
|
+
export declare const isOpen: (t: Threat) => boolean;
|
|
52
|
+
export declare function strideFor(typeOrKind: string | undefined): readonly StrideCategory[];
|
|
53
|
+
/** The nearest trust boundary above `nodeId` on the viewed plane, or undefined
|
|
54
|
+
* outside every boundary. Containment is a DAG; the first parent by declaration
|
|
55
|
+
* order is the one followed — the same pick a reader makes from the drawing. */
|
|
56
|
+
export declare function boundaryOf(m: DiagramModel, plane: string | undefined, nodeId: string): string | undefined;
|
|
57
|
+
/** Every relation whose two ends sit in different boundaries — the flows STRIDE
|
|
58
|
+
* cares about. Derived from containment, not authored, and not filtered by kind:
|
|
59
|
+
* a boundary crossing is a fact about the drawing, whatever the arrow means. */
|
|
60
|
+
export declare function crossings(m: DiagramModel, plane: string | undefined): ReadonlyMap<string, Crossing>;
|
|
61
|
+
/** One end of a crossing, as a reader sees it: *outside* where the end sits in
|
|
62
|
+
* no boundary at all, otherwise the boundary node's name — or, for an id no node
|
|
63
|
+
* carries, the id itself, so a broken model still says which one it means. */
|
|
64
|
+
export declare function boundaryName(m: DiagramModel, id: string | undefined): string;
|
|
65
|
+
/** `DMZ ⇢ Backend`: a crossing read between the two BOUNDARIES its ends sit in.
|
|
66
|
+
* The other arrow is the whole point — a flow's own line reads `from → to` and
|
|
67
|
+
* names the elements, this one names the trust zones the flow leaves and enters.
|
|
68
|
+
* Formatted here rather than at each call site so the studio panel and the
|
|
69
|
+
* published table cannot word the same fact differently. */
|
|
70
|
+
export declare function crossingLabel(m: DiagramModel, crossing: Crossing): string;
|
|
71
|
+
/** The register: every threat in the model, nodes first then relations, each in
|
|
72
|
+
* declaration order — the one order the studio list and the published table share. */
|
|
73
|
+
export declare function threatRegister(m: DiagramModel): ThreatRow[];
|
|
74
|
+
export declare function threatSummary(threats: readonly Threat[] | undefined): {
|
|
75
|
+
open: number;
|
|
76
|
+
total: number;
|
|
77
|
+
};
|
|
78
|
+
/** the status a bubble's chip advances to on a click: the THREAT_STATUSES
|
|
79
|
+
* order, wrapping — an absent status is `open`, as isOpen reads it */
|
|
80
|
+
export declare function nextThreatStatus(t: Threat): ThreatStatus;
|
|
81
|
+
/**
|
|
82
|
+
* Whether every threat-bearing element in the model has an open bubble on
|
|
83
|
+
* `planeKey` — the studio's `Notes` chip reads pressed exactly then, so a
|
|
84
|
+
* press always does the thing the picture is missing (opens the rest, or
|
|
85
|
+
* closes all). Model-wide, like set-notes-open: the chip and the command must
|
|
86
|
+
* agree on what "all" means. False when nothing carries a threat.
|
|
87
|
+
*/
|
|
88
|
+
export declare function allNotesOpen(model: DiagramModel, layout: LayoutOverlay, planeKey: string): boolean;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { relationLabels } from './labels.js';
|
|
2
|
+
import { STRIDE, THREAT_STATUSES, } from './types.js';
|
|
3
|
+
import { containmentPlaneOf } from './view/hierarchy.js';
|
|
4
|
+
/** The notation id a plane (or the model) declares to be drawn as a STRIDE
|
|
5
|
+
* data-flow diagram. */
|
|
6
|
+
export const TM_NOTATION = 'threat-model';
|
|
7
|
+
export const TM_ENTITY_TYPE = 'tm-entity';
|
|
8
|
+
export const TM_PROCESS_TYPE = 'tm-process';
|
|
9
|
+
export const TM_STORE_TYPE = 'tm-store';
|
|
10
|
+
/** A trust boundary is a CONTAINER, never a box with arrows: "this flow crosses
|
|
11
|
+
* a boundary" is then derived from containment (see crossings) instead of being
|
|
12
|
+
* authored, and cannot disagree with the drawing. */
|
|
13
|
+
export const TM_BOUNDARY_TYPE = 'tm-boundary';
|
|
14
|
+
export const TM_FLOW_KIND = 'data-flow';
|
|
15
|
+
export const TM_TYPES = new Set([TM_ENTITY_TYPE, TM_PROCESS_TYPE, TM_STORE_TYPE, TM_BOUNDARY_TYPE]);
|
|
16
|
+
export const isThreatModelNode = (n) => n.type !== undefined && TM_TYPES.has(n.type);
|
|
17
|
+
export const STRIDE_NAMES = {
|
|
18
|
+
S: 'Spoofing',
|
|
19
|
+
T: 'Tampering',
|
|
20
|
+
R: 'Repudiation',
|
|
21
|
+
I: 'Information disclosure',
|
|
22
|
+
D: 'Denial of service',
|
|
23
|
+
E: 'Elevation of privilege',
|
|
24
|
+
};
|
|
25
|
+
/** The title a threat gets when it is added from the canvas, before anyone has
|
|
26
|
+
* typed one. It is a real title rather than `''` because a threat with an empty
|
|
27
|
+
* title fails validation (`threat-title`) — and the studio autosaves on a timer,
|
|
28
|
+
* so an empty one would reach the save handler as a 400 while the user is still
|
|
29
|
+
* typing. Lives in core so every host seeds the same word, and so "was this ever
|
|
30
|
+
* titled?" is one comparison rather than a per-host convention. */
|
|
31
|
+
export const NEW_THREAT_TITLE = 'New threat';
|
|
32
|
+
/** The overlay key a threat target is filed under (`LayoutOverlay.notes`). Node
|
|
33
|
+
* and relation ids are separate namespaces — nothing stops a node and a relation
|
|
34
|
+
* sharing an id — so one flat map needs the prefix. */
|
|
35
|
+
export const threatTargetKey = (t) => ('node' in t ? `node:${t.node}` : `relation:${t.relation}`);
|
|
36
|
+
/** The element's threat list — `[]` when it carries none, undefined when there
|
|
37
|
+
* is no such element. The two are different answers: a note is drawn for the
|
|
38
|
+
* first case's element and the second is a dangling reference. */
|
|
39
|
+
export function threatsOf(m, t) {
|
|
40
|
+
const el = 'node' in t ? m.nodes.find((n) => n.id === t.node) : m.relations.find((r) => r.id === t.relation);
|
|
41
|
+
return el === undefined ? undefined : (el.threats ?? []);
|
|
42
|
+
}
|
|
43
|
+
/** First free `t<n>`. Ids are scoped to their element, so a removed `t2` is
|
|
44
|
+
* handed out again rather than the counter climbing forever. */
|
|
45
|
+
export function nextThreatId(threats) {
|
|
46
|
+
const taken = new Set(threats.map((t) => t.id));
|
|
47
|
+
let n = 1;
|
|
48
|
+
while (taken.has(`t${n}`))
|
|
49
|
+
n += 1;
|
|
50
|
+
return `t${n}`;
|
|
51
|
+
}
|
|
52
|
+
export const isOpen = (t) => (t.status ?? 'open') === 'open';
|
|
53
|
+
/** STRIDE-per-element (the Microsoft table): which categories apply to a DFD
|
|
54
|
+
* element. A type outside the DFD vocabulary — a C4 container being
|
|
55
|
+
* threat-modelled on its own plane — gets every category offered. */
|
|
56
|
+
const STRIDE_FOR = {
|
|
57
|
+
[TM_ENTITY_TYPE]: ['S', 'R'],
|
|
58
|
+
[TM_PROCESS_TYPE]: STRIDE,
|
|
59
|
+
[TM_STORE_TYPE]: ['T', 'R', 'I', 'D'],
|
|
60
|
+
[TM_FLOW_KIND]: ['T', 'I', 'D'],
|
|
61
|
+
};
|
|
62
|
+
export function strideFor(typeOrKind) {
|
|
63
|
+
return (typeOrKind !== undefined ? STRIDE_FOR[typeOrKind] : undefined) ?? STRIDE;
|
|
64
|
+
}
|
|
65
|
+
/** child → parents on the plane a view of `plane` uses (containmentOf resolved;
|
|
66
|
+
* an untagged edge belongs to the first-declared plane), declaration order kept */
|
|
67
|
+
function planeParents(m, plane) {
|
|
68
|
+
const active = containmentPlaneOf(m, plane);
|
|
69
|
+
const defaultPlane = m.planes?.[0]?.id;
|
|
70
|
+
const parents = new Map();
|
|
71
|
+
for (const e of m.containment) {
|
|
72
|
+
if ((e.plane ?? defaultPlane) !== active)
|
|
73
|
+
continue;
|
|
74
|
+
parents.set(e.child, [...(parents.get(e.child) ?? []), e.parent]);
|
|
75
|
+
}
|
|
76
|
+
return parents;
|
|
77
|
+
}
|
|
78
|
+
function nearestBoundary(parents, typeOf, id) {
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
let cur = parents.get(id)?.[0];
|
|
81
|
+
// `seen` is belt-and-braces: validation rejects containment cycles, but a
|
|
82
|
+
// derivation that runs every frame must not be able to spin on a bad model.
|
|
83
|
+
while (cur !== undefined && !seen.has(cur)) {
|
|
84
|
+
if (typeOf.get(cur) === TM_BOUNDARY_TYPE)
|
|
85
|
+
return cur;
|
|
86
|
+
seen.add(cur);
|
|
87
|
+
cur = parents.get(cur)?.[0];
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/** The nearest trust boundary above `nodeId` on the viewed plane, or undefined
|
|
92
|
+
* outside every boundary. Containment is a DAG; the first parent by declaration
|
|
93
|
+
* order is the one followed — the same pick a reader makes from the drawing. */
|
|
94
|
+
export function boundaryOf(m, plane, nodeId) {
|
|
95
|
+
return nearestBoundary(planeParents(m, plane), new Map(m.nodes.map((n) => [n.id, n.type])), nodeId);
|
|
96
|
+
}
|
|
97
|
+
/** Every relation whose two ends sit in different boundaries — the flows STRIDE
|
|
98
|
+
* cares about. Derived from containment, not authored, and not filtered by kind:
|
|
99
|
+
* a boundary crossing is a fact about the drawing, whatever the arrow means. */
|
|
100
|
+
export function crossings(m, plane) {
|
|
101
|
+
const parents = planeParents(m, plane);
|
|
102
|
+
const typeOf = new Map(m.nodes.map((n) => [n.id, n.type]));
|
|
103
|
+
const cache = new Map();
|
|
104
|
+
const bOf = (id) => {
|
|
105
|
+
if (!cache.has(id))
|
|
106
|
+
cache.set(id, nearestBoundary(parents, typeOf, id));
|
|
107
|
+
return cache.get(id);
|
|
108
|
+
};
|
|
109
|
+
const out = new Map();
|
|
110
|
+
for (const r of m.relations) {
|
|
111
|
+
const from = bOf(r.from);
|
|
112
|
+
const to = bOf(r.to);
|
|
113
|
+
if (from === to)
|
|
114
|
+
continue;
|
|
115
|
+
// an absent side means "outside every boundary" — omitted rather than
|
|
116
|
+
// `undefined` so a serialized Crossing stays minimal
|
|
117
|
+
out.set(r.id, { ...(from !== undefined ? { from } : {}), ...(to !== undefined ? { to } : {}) });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/** One end of a crossing, as a reader sees it: *outside* where the end sits in
|
|
122
|
+
* no boundary at all, otherwise the boundary node's name — or, for an id no node
|
|
123
|
+
* carries, the id itself, so a broken model still says which one it means. */
|
|
124
|
+
export function boundaryName(m, id) {
|
|
125
|
+
return id === undefined ? 'outside' : (m.nodes.find((n) => n.id === id)?.name ?? id);
|
|
126
|
+
}
|
|
127
|
+
/** `DMZ ⇢ Backend`: a crossing read between the two BOUNDARIES its ends sit in.
|
|
128
|
+
* The other arrow is the whole point — a flow's own line reads `from → to` and
|
|
129
|
+
* names the elements, this one names the trust zones the flow leaves and enters.
|
|
130
|
+
* Formatted here rather than at each call site so the studio panel and the
|
|
131
|
+
* published table cannot word the same fact differently. */
|
|
132
|
+
export function crossingLabel(m, crossing) {
|
|
133
|
+
return `${boundaryName(m, crossing.from)} ⇢ ${boundaryName(m, crossing.to)}`;
|
|
134
|
+
}
|
|
135
|
+
/** The register: every threat in the model, nodes first then relations, each in
|
|
136
|
+
* declaration order — the one order the studio list and the published table share. */
|
|
137
|
+
export function threatRegister(m) {
|
|
138
|
+
const rows = [];
|
|
139
|
+
for (const n of m.nodes)
|
|
140
|
+
for (const threat of n.threats ?? [])
|
|
141
|
+
rows.push({ target: { node: n.id }, name: n.name, threat });
|
|
142
|
+
const nameOf = (id) => m.nodes.find((n) => n.id === id)?.name ?? id;
|
|
143
|
+
for (const r of m.relations) {
|
|
144
|
+
if (r.threats === undefined || r.threats.length === 0)
|
|
145
|
+
continue;
|
|
146
|
+
const label = relationLabels(r)[0]?.text;
|
|
147
|
+
const name = `${nameOf(r.from)} → ${nameOf(r.to)}${label !== undefined && label !== '' ? ` (${label})` : ''}`;
|
|
148
|
+
for (const threat of r.threats)
|
|
149
|
+
rows.push({ target: { relation: r.id }, name, threat });
|
|
150
|
+
}
|
|
151
|
+
return rows;
|
|
152
|
+
}
|
|
153
|
+
export function threatSummary(threats) {
|
|
154
|
+
const list = threats ?? [];
|
|
155
|
+
return { open: list.filter(isOpen).length, total: list.length };
|
|
156
|
+
}
|
|
157
|
+
/** the status a bubble's chip advances to on a click: the THREAT_STATUSES
|
|
158
|
+
* order, wrapping — an absent status is `open`, as isOpen reads it */
|
|
159
|
+
export function nextThreatStatus(t) {
|
|
160
|
+
const i = THREAT_STATUSES.indexOf(t.status ?? 'open');
|
|
161
|
+
return THREAT_STATUSES[(i + 1) % THREAT_STATUSES.length] ?? 'open';
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Whether every threat-bearing element in the model has an open bubble on
|
|
165
|
+
* `planeKey` — the studio's `Notes` chip reads pressed exactly then, so a
|
|
166
|
+
* press always does the thing the picture is missing (opens the rest, or
|
|
167
|
+
* closes all). Model-wide, like set-notes-open: the chip and the command must
|
|
168
|
+
* agree on what "all" means. False when nothing carries a threat.
|
|
169
|
+
*/
|
|
170
|
+
export function allNotesOpen(model, layout, planeKey) {
|
|
171
|
+
const bucket = layout.notes?.[planeKey];
|
|
172
|
+
let any = false;
|
|
173
|
+
for (const n of model.nodes) {
|
|
174
|
+
if ((n.threats?.length ?? 0) === 0)
|
|
175
|
+
continue;
|
|
176
|
+
any = true;
|
|
177
|
+
if (bucket?.[threatTargetKey({ node: n.id })]?.open !== true)
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
for (const r of model.relations) {
|
|
181
|
+
if ((r.threats?.length ?? 0) === 0)
|
|
182
|
+
continue;
|
|
183
|
+
any = true;
|
|
184
|
+
if (bucket?.[threatTargetKey({ relation: r.id })]?.open !== true)
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
return any;
|
|
188
|
+
}
|