Add filter policy playground

This commit is contained in:
mikecarper
2026-08-06 22:34:19 -07:00
parent df2dd41512
commit 3d26b0b39c
6 changed files with 2909 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+585
View File
@@ -0,0 +1,585 @@
.filter-tool {
--filter-border: color-mix(in srgb, var(--md-default-fg-color) 18%, transparent);
--filter-border-strong: color-mix(in srgb, var(--md-primary-fg-color) 46%, transparent);
--filter-soft: color-mix(in srgb, var(--md-default-fg-color) 5%, transparent);
--filter-softer: color-mix(in srgb, var(--md-default-fg-color) 2.5%, transparent);
--filter-accent: color-mix(in srgb, var(--md-primary-fg-color) 12%, transparent);
--filter-warning: #ed9b00;
--filter-danger: #d32f2f;
margin: 1.5rem 0 2rem;
color: var(--md-default-fg-color);
}
.filter-design-warning {
margin: 1rem 0;
padding: 0.75rem 0.9rem;
border-left: 0.25rem solid #ed9b00;
border-radius: 0.25rem;
background: color-mix(in srgb, #ed9b00 11%, transparent);
}
.filter-design-warning strong {
display: block;
margin-bottom: 0.2rem;
}
.filter-design-warning p {
margin: 0;
}
.filter-tool [hidden] {
display: none !important;
}
.filter-tool-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.8rem;
border: 1px solid var(--filter-border);
border-radius: 0.65rem;
background:
linear-gradient(120deg, var(--filter-accent), transparent 56%),
var(--md-default-bg-color);
}
.filter-tool-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr);
gap: 1rem;
align-items: start;
}
.filter-panel {
min-width: 0;
padding: 1rem;
border: 1px solid var(--filter-border);
border-radius: 0.65rem;
background: var(--md-default-bg-color);
box-shadow: 0 0.15rem 0.65rem color-mix(in srgb, #000 9%, transparent);
}
.filter-panel + .filter-panel,
.filter-tool-grid + .filter-panel {
margin-top: 1rem;
}
.filter-tool-grid .filter-panel + .filter-panel {
margin-top: 0;
}
.filter-panel-heading {
display: flex;
gap: 0.75rem;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--filter-border);
}
.filter-panel-heading h2 {
margin: 0.12rem 0 0 !important;
font-size: 1.22rem;
line-height: 1.25;
}
.filter-eyebrow {
margin: 0;
color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.filter-tool h3 {
margin: 1rem 0 0.55rem;
font-size: 0.88rem;
letter-spacing: 0.015em;
}
.filter-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.72rem;
}
.filter-tool label {
display: block;
min-width: 0;
color: color-mix(in srgb, var(--md-default-fg-color) 82%, transparent);
font-size: 0.76rem;
font-weight: 750;
}
.filter-tool input,
.filter-tool select,
.filter-tool textarea {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 0.3rem;
border: 1px solid var(--filter-border);
border-radius: 0.38rem;
color: var(--md-default-fg-color);
background: var(--md-code-bg-color);
font: inherit;
}
.filter-tool input,
.filter-tool select {
min-height: 2.35rem;
padding: 0.4rem 0.55rem;
}
.filter-tool textarea {
min-height: 9rem;
padding: 0.72rem;
resize: vertical;
font: 0.76rem/1.5 var(--md-code-font-family);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.filter-tool input:focus-visible,
.filter-tool select:focus-visible,
.filter-tool textarea:focus-visible,
.filter-tool button:focus-visible {
outline: 0.15rem solid var(--md-accent-fg-color);
outline-offset: 0.12rem;
}
.filter-tool input::placeholder,
.filter-tool textarea::placeholder {
color: color-mix(in srgb, var(--md-code-fg-color) 48%, transparent);
}
.filter-check-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 1rem;
margin-top: 0.8rem;
padding: 0.68rem 0.75rem;
border: 1px solid var(--filter-border);
border-radius: 0.4rem;
background: var(--filter-soft);
}
.filter-check-row label {
display: inline-flex;
gap: 0.45rem;
align-items: center;
margin: 0;
cursor: pointer;
}
.filter-check-row input {
width: 1rem;
min-height: 1rem;
height: 1rem;
margin: 0;
padding: 0;
}
.filter-tool button {
appearance: none;
min-height: 2.2rem;
padding: 0.4rem 0.72rem;
border: 1px solid var(--filter-border);
border-radius: 0.35rem;
color: var(--md-default-fg-color);
background: var(--filter-soft);
font: inherit;
font-size: 0.76rem;
font-weight: 750;
cursor: pointer;
}
.filter-tool button:hover:not(:disabled) {
border-color: var(--filter-border-strong);
background: var(--filter-accent);
}
.filter-tool button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.filter-tool .filter-primary-action {
border-color: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
background: var(--md-primary-fg-color);
}
.filter-tool .filter-primary-action:hover:not(:disabled) {
filter: brightness(1.12);
background: var(--md-primary-fg-color);
}
.filter-tool .filter-danger-action {
color: color-mix(in srgb, var(--filter-danger) 82%, var(--md-default-fg-color));
}
.filter-field-help {
margin: 0.65rem 0;
color: color-mix(in srgb, var(--md-default-fg-color) 67%, transparent);
font-size: 0.76rem;
line-height: 1.48;
}
.filter-live-preview {
display: grid;
gap: 0.6rem;
margin-top: 1rem;
padding: 0.8rem;
border: 1px solid var(--filter-border-strong);
border-radius: 0.48rem;
background:
linear-gradient(135deg, var(--filter-accent), transparent 62%),
var(--filter-softer);
}
.filter-live-preview > div {
min-width: 0;
}
.filter-preview-label {
display: block;
margin-bottom: 0.32rem;
color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent);
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
}
.filter-live-preview code,
.filter-rule-command,
.filter-explain-results code {
display: block;
max-width: 100%;
padding: 0.58rem 0.65rem;
border: 1px solid var(--filter-border);
border-radius: 0.35rem;
color: var(--md-code-fg-color);
background: var(--md-code-bg-color);
font-size: 0.72rem;
line-height: 1.5;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.filter-live-preview p {
margin: 0;
font-size: 0.78rem;
line-height: 1.48;
}
.filter-builder-actions,
.filter-rule-actions {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
margin-top: 0.75rem;
}
.filter-rule-list {
display: grid;
gap: 0.72rem;
margin: 0.85rem 0 0;
padding: 0;
list-style: none;
}
.filter-rule-list > li {
margin: 0;
}
.filter-rule-card {
padding: 0.75rem;
border: 1px solid var(--filter-border);
border-radius: 0.48rem;
background: var(--filter-softer);
}
.filter-rule-card-header {
display: flex;
gap: 0.75rem;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.55rem;
}
.filter-rule-labels {
display: grid;
gap: 0.12rem;
}
.filter-rule-labels strong {
font-size: 0.81rem;
}
.filter-rule-labels span,
.filter-rule-meta {
color: color-mix(in srgb, var(--md-default-fg-color) 62%, transparent);
font-size: 0.66rem;
}
.filter-rule-meta {
text-align: right;
}
.filter-rule-card p {
margin: 0.62rem 0 0;
font-size: 0.75rem;
line-height: 1.48;
}
.filter-rule-actions {
margin-top: 0.62rem;
}
.filter-rule-actions button {
min-height: 1.9rem;
padding: 0.26rem 0.55rem;
font-size: 0.7rem;
}
.filter-empty-state {
margin-top: 0.85rem;
padding: 2.2rem 1rem;
border: 1px dashed var(--filter-border-strong);
border-radius: 0.48rem;
color: color-mix(in srgb, var(--md-default-fg-color) 58%, transparent);
background: var(--filter-softer);
text-align: center;
font-size: 0.78rem;
}
.filter-policy-summary {
margin-top: 0.85rem;
padding-top: 0.65rem;
border-top: 1px solid var(--filter-border);
color: color-mix(in srgb, var(--md-default-fg-color) 68%, transparent);
font-size: 0.7rem;
font-weight: 700;
}
.filter-policy-target {
margin-top: 0.85rem;
}
.filter-policy-target label {
max-width: 18rem;
}
.filter-inline-warnings {
margin: 0.58rem 0 0 1rem;
padding: 0;
color: color-mix(in srgb, var(--filter-warning) 80%, var(--md-default-fg-color));
font-size: 0.71rem;
line-height: 1.42;
}
.filter-inline-warnings li + li {
margin-top: 0.2rem;
}
.filter-import textarea,
.filter-export textarea {
min-height: 10rem;
}
.filter-error {
margin-top: 0.85rem;
padding: 0.7rem 0.8rem;
border-left: 0.25rem solid var(--filter-danger);
border-radius: 0.25rem;
color: color-mix(in srgb, var(--filter-danger) 82%, var(--md-default-fg-color));
background: color-mix(in srgb, var(--filter-danger) 10%, transparent);
font-size: 0.76rem;
}
.filter-simulator-facts {
margin-top: 0.85rem;
}
.filter-tool .filter-simulator-check {
display: flex;
gap: 0.55rem;
align-items: center;
align-self: end;
min-height: 2.35rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--filter-border);
border-radius: 0.38rem;
background: var(--filter-soft);
cursor: pointer;
}
.filter-tool .filter-simulator-check input {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
min-height: 1rem;
margin: 0;
padding: 0;
}
.filter-decision {
margin-top: 1rem;
padding: 0.85rem;
border: 1px solid var(--filter-border-strong);
border-radius: 0.48rem;
background: var(--filter-softer);
}
.filter-decision > h3:first-child {
margin-top: 0;
}
.filter-decision-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.55rem;
}
.filter-decision-grid > div {
display: grid;
gap: 0.15rem;
padding: 0.58rem;
border: 1px solid var(--filter-border);
border-radius: 0.35rem;
background: var(--md-code-bg-color);
}
.filter-decision-grid span {
color: color-mix(in srgb, var(--md-default-fg-color) 58%, transparent);
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.filter-decision-grid strong {
font-size: 0.72rem;
overflow-wrap: anywhere;
}
.filter-trace {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.filter-trace li {
display: grid;
gap: 0.18rem;
margin: 0;
padding: 0.58rem 0.65rem;
border-left: 0.22rem solid var(--filter-border-strong);
border-radius: 0.28rem;
background: var(--filter-soft);
font-size: 0.72rem;
line-height: 1.42;
}
.filter-trace li strong {
font-size: 0.7rem;
text-transform: uppercase;
}
.filter-trace-match {
border-left-color: #2e7d32 !important;
}
.filter-trace-shadow {
border-left-color: var(--filter-warning) !important;
}
.filter-trace-miss,
.filter-trace-disabled,
.filter-trace-stopped {
opacity: 0.72;
}
.filter-explain-results {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--filter-border);
}
.filter-explain-results h3 {
margin-top: 0;
}
.filter-explain-results ol {
display: grid;
gap: 0.75rem;
margin: 0;
padding-left: 1.4rem;
}
.filter-explain-results li {
padding-left: 0.2rem;
}
.filter-explain-results p {
margin: 0.45rem 0 0;
font-size: 0.76rem;
line-height: 1.48;
}
.filter-export-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.85rem 0;
padding-bottom: 0.65rem;
border-bottom: 1px solid var(--filter-border);
}
.filter-export-tabs button[aria-selected="true"] {
border-color: var(--md-primary-fg-color);
color: var(--md-primary-bg-color);
background: var(--md-primary-fg-color);
}
@media (max-width: 62rem) {
.filter-tool-grid {
grid-template-columns: 1fr;
}
.filter-tool-grid .filter-panel + .filter-panel {
margin-top: 0;
}
}
@media (max-width: 44rem) {
.filter-panel {
padding: 0.75rem;
}
.filter-form-grid {
grid-template-columns: 1fr;
}
.filter-panel-heading,
.filter-rule-card-header {
align-items: stretch;
flex-direction: column;
}
.filter-rule-meta {
text-align: left;
}
.filter-tool-toolbar button,
.filter-builder-actions button {
flex: 1 1 auto;
}
}
+491
View File
@@ -0,0 +1,491 @@
# Filter policy playground
Design and test policies for the proposed ground-up MeshCore forwarding engine.
The playground models phased evaluation, immutable receive-time matches,
explicit priority and stop behavior, ACL ownership, compact typed conditions,
and accumulated forwarding decisions.
Everything runs locally in this browser. Channel keys, packet facts, and policy
drafts are not uploaded anywhere.
<div class="filter-design-warning" role="note">
<strong>Engine design preview</strong>
<p>
This page targets the new policy-engine idea, not today's FPF7 file or
existing <code>set flood.*</code> commands. Its readable policy language,
JSON, and Base64 bundle are a prototype for design testing. Current
firmware cannot install these policies yet.
</p>
</div>
## Build a policy
<div class="filter-tool" data-filter-tool>
<div class="filter-tool-toolbar" aria-label="Policy examples">
<strong>Load an example:</strong>
<button type="button" data-example="blackhole">BlackHole86 rewrite</button>
<button type="button" data-example="wildcards">Login and other classes</button>
<button type="button" data-example="moderation">Content moderation</button>
<button type="button" data-example="system">System forwarding gates</button>
<button type="button" data-example="mixed">Full mixed policy</button>
</div>
<div class="filter-tool-grid">
<section class="filter-panel filter-builder" aria-labelledby="filter-builder-title">
<div class="filter-panel-heading">
<div>
<p class="filter-eyebrow">Rule builder</p>
<h2 id="filter-builder-title">Create a policy rule</h2>
</div>
<button type="button" data-role="reset-form">Reset form</button>
</div>
<h3>Identity and execution</h3>
<div class="filter-form-grid">
<label>
Stable rule ID
<input data-field="id" value="rule-1" maxlength="32" placeholder="blackhole-rewrite">
</label>
<label>
Processing phase
<select data-field="phase">
<option value="scope_gate">1 - Incoming scope gate</option>
<option value="rewrite">2 - Scope rewrite</option>
<option value="forward">3 - Forwarding decision</option>
<option value="content">4 - Decrypted content</option>
<option value="schedule">5 - Scheduling and retry</option>
</select>
</label>
<label>
Rule owner / capability
<select data-field="owner">
<option value="scope">ACL 4 - Region/scope manager</option>
<option value="filter">ACL 5 - Filter manager</option>
<option value="admin">Administrator</option>
<option value="system">Firmware-managed system rule</option>
</select>
</label>
<label>
Priority
<input data-field="priority" type="number" min="0" max="255" inputmode="numeric" value="100">
</label>
<label>
Mode
<select data-field="mode">
<option value="active">Active</option>
<option value="shadow">Shadow - observe only</option>
<option value="disabled">Disabled</option>
</select>
</label>
<label>
Stop behavior after a match
<select data-field="stop">
<option value="none">Continue processing</option>
<option value="phase">Stop this phase</option>
<option value="policy">Stop later policy phases</option>
</select>
</label>
</div>
<h3>Match immutable receive-time facts</h3>
<div class="filter-form-grid">
<label>
Route class
<select data-field="route">
<option value="flood">Any flood route</option>
<option value="unscoped_flood">Unscoped flood only</option>
<option value="scoped_flood">Transport-scoped flood only</option>
<option value="direct">Direct routes</option>
<option value="any">Any route</option>
</select>
</label>
<label>
Payload type or class
<select data-field="type">
<option value="any">Any payload type</option>
<option value="class:group">Class: group text and data</option>
<option value="class:login">Class: login/admin family</option>
<option value="class:other">Class: every other payload</option>
<option value="req">REQ</option>
<option value="response">RESPONSE</option>
<option value="txt_msg">TXT_MSG</option>
<option value="ack">ACK</option>
<option value="advert">ADVERT</option>
<option value="grp_txt">GRP_TXT</option>
<option value="grp_data">GRP_DATA</option>
<option value="anon_req">ANON_REQ</option>
<option value="path">PATH</option>
<option value="trace">TRACE</option>
<option value="multipart">MULTIPART</option>
<option value="control">CONTROL</option>
<option value="ota">OTA</option>
<option value="13">Reserved type 13</option>
<option value="14">Reserved type 14</option>
<option value="raw_custom">RAW_CUSTOM</option>
</select>
</label>
<label>
Received hops
<input data-field="hops" value="all" placeholder="all, 5+, 2-6, or 3">
</label>
<label>
Authenticated channel (optional)
<input data-field="channel" placeholder="#rgdata, public, or key">
</label>
<label>
Original incoming scope
<input data-field="incoming" list="policy-incoming-options" value="any" placeholder="any">
<datalist id="policy-incoming-options">
<option value="any"></option>
<option value="none"></option>
<option value="scoped"></option>
<option value="allowed"></option>
<option value="unknown"></option>
<option value="scope:usa"></option>
<option value="region:west"></option>
</datalist>
</label>
<label>
Path matcher
<select data-field="path-kind">
<option value="none">No path condition</option>
<option value="prefix">Ordered 1/2/3-byte pbyte prefix</option>
<option value="blacklist">Passive blacklist</option>
<option value="bucket:1">Path bucket 1</option>
<option value="bucket:2">Path bucket 2</option>
<option value="bucket:3">Path bucket 3</option>
<option value="bucket:4">Path bucket 4</option>
<option value="bucket:5">Path bucket 5</option>
<option value="bucket:6">Path bucket 6</option>
<option value="loop:strict">Own-ID loop: strict</option>
<option value="loop:moderate">Own-ID loop: moderate</option>
<option value="loop:minimal">Own-ID loop: minimal</option>
</select>
</label>
<label>
Ordered pbyte prefix
<input data-field="path-prefix" placeholder="86, 860C, or 860C,12A4">
</label>
<label>
Decrypted sender (optional)
<input data-field="sender" maxlength="31" placeholder="Noisy User">
</label>
<label>
Temporary-radio state
<select data-field="temp-radio">
<option value="any">Either state</option>
<option value="inactive">Inactive</option>
<option value="active">Active</option>
</select>
</label>
<label>
Minimum SNR dB (optional)
<input data-field="snr-min" type="number" min="-30" max="30" step="0.25" placeholder="no minimum">
</label>
<label>
Maximum SNR dB (optional)
<input data-field="snr-max" type="number" min="-30" max="30" step="0.25" placeholder="no maximum">
</label>
</div>
<h3>Accumulate forwarding actions</h3>
<div class="filter-form-grid">
<label>
Forwarding verdict
<select data-field="verdict">
<option value="continue">No verdict change</option>
<option value="drop">Drop / do not retransmit</option>
</select>
</label>
<label>
Region-gate decision
<select data-field="scope-gate">
<option value="unchanged">Leave global gate unchanged</option>
<option value="require_allowed">Require original allowed region</option>
<option value="bypass_global">Bypass global gate for this packet</option>
</select>
</label>
<label>
Scope rewrite target
<select data-field="target-kind">
<option value="none">Do not rewrite scope</option>
<option value="scope">Regionless public scope</option>
<option value="region">Configured region</option>
</select>
</label>
<label>
Target name
<input data-field="target" placeholder="BlackHole86">
</label>
<label>
Token rate per minute (optional)
<input data-field="rate" type="number" min="1" max="65534" inputmode="numeric" placeholder="unlimited">
</label>
<label>
Burst tokens
<input data-field="burst" type="number" min="1" max="65534" inputmode="numeric" placeholder="defaults to rate">
</label>
<label>
Rewrite timing
<select data-field="timing">
<option value="inherit">Inherit normal behavior</option>
<option value="fast">Fast-track rewrite</option>
<option value="normal">Normal queue timing</option>
<option value="slow">Slow receive/transmit timing</option>
</select>
</label>
<label>
Queue priority
<select data-field="queue">
<option value="inherit">Inherit</option>
<option value="high">High</option>
<option value="normal">Normal</option>
<option value="low">Low</option>
</select>
</label>
<label>
Retry path bucket
<select data-field="retry-bucket">
<option value="none">No retry action</option>
<option value="bucket:1">Bucket 1</option>
<option value="bucket:2">Bucket 2</option>
<option value="bucket:3">Bucket 3</option>
<option value="bucket:4">Bucket 4</option>
<option value="bucket:5">Bucket 5</option>
<option value="bucket:6">Bucket 6</option>
</select>
</label>
<label>
Retry attempts
<input data-field="retry-attempts" type="number" min="1" max="10" inputmode="numeric" placeholder="1">
</label>
<label>
Decision tag (optional)
<input data-field="tag" maxlength="24" placeholder="blackhole-ingress">
</label>
</div>
<div class="filter-live-preview">
<div>
<span class="filter-preview-label">Readable policy definition</span>
<code data-role="live-command"></code>
</div>
<p data-role="live-explanation"></p>
<ul class="filter-inline-warnings" data-role="live-warnings" hidden></ul>
</div>
<div class="filter-builder-actions">
<button class="filter-primary-action" type="button" data-role="save-rule">Add rule to policy</button>
<button type="button" data-role="copy-live">Copy definition</button>
</div>
</section>
<section class="filter-panel filter-policy" aria-labelledby="filter-policy-title">
<div class="filter-panel-heading">
<div>
<p class="filter-eyebrow">Policy draft</p>
<h2 id="filter-policy-title">Rules in execution order</h2>
</div>
<button type="button" data-role="clear-policy">Clear policy</button>
</div>
<div class="filter-policy-target">
<label>
Approximate target budget
<select data-role="target-profile">
<option value="stm32">Compact STM32 - 2 KB</option>
<option value="nrf52" selected>nRF52 - 8 KB</option>
<option value="esp32">Classic ESP32 - 16 KB</option>
<option value="esp32_roomy">Roomy ESP32 - 64 KB</option>
</select>
</label>
</div>
<p class="filter-field-help">
Rules match the same immutable packet facts. Ordering is phase, then
descending priority, then stable rule ID. Drop decisions are sticky.
</p>
<div class="filter-empty-state" data-role="empty-policy">
Add a rule or load an example to start exploring.
</div>
<ol class="filter-rule-list" data-role="rule-list"></ol>
<div class="filter-policy-summary" data-role="policy-summary"></div>
<ul class="filter-inline-warnings" data-role="policy-warnings" hidden></ul>
</section>
</div>
<section class="filter-panel filter-simulator" aria-labelledby="filter-simulator-title">
<div class="filter-panel-heading">
<div>
<p class="filter-eyebrow">Immutable packet simulator</p>
<h2 id="filter-simulator-title">Explain an evaluation</h2>
</div>
<button type="button" data-role="reset-packet">Reset packet</button>
</div>
<div class="filter-form-grid filter-simulator-facts">
<label>
Received route
<select data-packet="route">
<option value="unscoped_flood">Unscoped flood</option>
<option value="scoped_flood">Transport-scoped flood</option>
<option value="direct">Direct route</option>
</select>
</label>
<label>
Payload type
<select data-packet="type">
<option value="req">REQ</option>
<option value="response">RESPONSE</option>
<option value="txt_msg">TXT_MSG</option>
<option value="ack">ACK</option>
<option value="advert">ADVERT</option>
<option value="grp_txt">GRP_TXT</option>
<option value="grp_data" selected>GRP_DATA</option>
<option value="anon_req">ANON_REQ</option>
<option value="path">PATH</option>
<option value="trace">TRACE</option>
<option value="multipart">MULTIPART</option>
<option value="control">CONTROL</option>
<option value="ota">OTA</option>
<option value="13">Reserved type 13</option>
<option value="14">Reserved type 14</option>
<option value="raw_custom">RAW_CUSTOM</option>
</select>
</label>
<label>
Received hops
<input data-packet="hops" type="number" min="0" max="63" inputmode="numeric" value="4">
</label>
<label>
Authenticated channel
<input data-packet="channel" value="#rgdata" placeholder="blank if none">
</label>
<label>
Received pbyte path
<input data-packet="path" value="860C,12A4" placeholder="same-width IDs">
</label>
<label>
Original scope status
<select data-packet="scope-status">
<option value="none">Unscoped</option>
<option value="allowed">Allowed region</option>
<option value="unknown">Unknown or denied scope</option>
</select>
</label>
<label>
Original scope name
<input data-packet="scope-name" placeholder="usa">
</label>
<label>
Resolved region name
<input data-packet="region-name" placeholder="west">
</label>
<label>
Decrypted sender
<input data-packet="sender" placeholder="Noisy User">
</label>
<label>
Received SNR dB
<input data-packet="snr" type="number" min="-30" max="30" step="0.25" value="6">
</label>
<label>
Passive blacklist result
<select data-packet="blacklist">
<option value="no">No match</option>
<option value="yes">Matched</option>
</select>
</label>
<label>
Matching path buckets
<input data-packet="buckets" placeholder="1,3,6">
</label>
<label>
Own-ID loop result
<select data-packet="loop-level">
<option value="0">No loop match</option>
<option value="1">Strict only</option>
<option value="2">Moderate and strict</option>
<option value="3">Minimal, moderate, and strict</option>
</select>
</label>
<label class="filter-simulator-check">
<input data-packet="temp-radio" type="checkbox">
Temporary radio is active
</label>
</div>
<div class="filter-builder-actions">
<button class="filter-primary-action" type="button" data-role="simulate">Run policy simulation</button>
</div>
<div class="filter-error" data-role="simulation-error" role="alert" hidden></div>
<div class="filter-decision" data-role="simulation-result" hidden></div>
</section>
<section class="filter-panel filter-import" aria-labelledby="filter-import-title">
<div class="filter-panel-heading">
<div>
<p class="filter-eyebrow">Import and explain</p>
<h2 id="filter-import-title">Paste readable policy or a saved draft</h2>
</div>
</div>
<p class="filter-field-help">
Accepts one-line <code>policy set ... when ... do ...</code> definitions,
playground JSON, or a playground Base64 bundle.
</p>
<textarea data-role="import-input" spellcheck="false" placeholder="policy set blackhole phase=rewrite owner=scope priority=150 when route=flood type=grp_data hops=4+ channel=#rgdata rx.scope=none do scope=#BlackHole86 timing=fast stop=phase"></textarea>
<div class="filter-builder-actions">
<button class="filter-primary-action" type="button" data-role="explain-input">Explain input</button>
<button type="button" data-role="load-input">Load into builder</button>
</div>
<div class="filter-error" data-role="import-error" role="alert" hidden></div>
<div class="filter-explain-results" data-role="explain-results" hidden></div>
</section>
<section class="filter-panel filter-export" aria-labelledby="filter-export-title">
<div class="filter-panel-heading">
<div>
<p class="filter-eyebrow">Export</p>
<h2 id="filter-export-title">Move or save this design</h2>
</div>
</div>
<div class="filter-export-tabs" role="tablist" aria-label="Policy export format">
<button type="button" data-export-tab="dsl" aria-selected="true">Readable policy</button>
<button type="button" data-export-tab="json" aria-selected="false">Policy JSON</button>
<button type="button" data-export-tab="bundle" aria-selected="false">Playground Base64</button>
</div>
<div data-export-panel="dsl">
<p class="filter-field-help">Proposed human-readable definition. Current firmware does not accept it yet.</p>
<textarea data-role="export-dsl" readonly spellcheck="false"></textarea>
</div>
<div data-export-panel="json" hidden>
<p class="filter-field-help">Structured draft used by this page.</p>
<textarea data-role="export-json" readonly spellcheck="false"></textarea>
</div>
<div data-export-panel="bundle" hidden>
<p class="filter-field-help">Browser-playground interchange only. It is not the final packed firmware codec.</p>
<textarea data-role="export-bundle" readonly spellcheck="false"></textarea>
</div>
<div class="filter-builder-actions">
<button type="button" data-role="copy-export">Copy visible export</button>
<button type="button" data-role="download-json">Download JSON</button>
</div>
</section>
</div>
## Proposed evaluation contract
The simulator uses these rules:
1. Every matcher reads the same immutable receive-time packet facts.
2. Rules run by phase, descending priority, then stable rule ID.
3. A drop decision is sticky and cannot be undone by a later rule.
4. The first matching scope, timing, queue, and retry action in execution order
wins.
5. All matching token-bucket rate constraints remain attached to the decision.
6. `stop=phase` skips later rules in that phase. `stop=policy` skips later
configurable rules, but never mandatory packet validation or radio safety.
7. Shadow rules report what they would do without changing the decision or
stopping other rules.
8. Expensive facts such as channel authentication, decryption, and path-table
lookup are resolved once per packet and reused by every matching rule.
The byte-budget display is deliberately approximate until the packed firmware
codec exists. It demonstrates why simple mappings should not reserve a maximum-
sized structure for every possible condition and action.
+1
View File
@@ -6,6 +6,7 @@ Below are a few quick start guides.
- [Frequently Asked Questions](./faq.md)
- [CLI Commands](./cli_commands.md)
- [Filter Policy Playground](./filter_tool.md)
- [Telemetry History Decoder](./telemetry_decoder.md)
- [CLI Availability by Firmware Build](./cli_build_matrix.md)
- [Easy LoRa OTA: ESP32 full images and nRF52 deltas](./ota_easy.md)
+2
View File
@@ -18,6 +18,8 @@ theme:
extra_css:
- _stylesheets/extra.css
- _stylesheets/telemetry_decoder.css
- _stylesheets/filter_tool.css
extra_javascript:
- _javascript/telemetry_decoder.js
- _javascript/filter_tool.js
+246
View File
@@ -0,0 +1,246 @@
"use strict";
const assert = require("assert");
const tool = require("../docs/_javascript/filter_tool.js");
let passed = 0;
function test(name, callback) {
callback();
passed += 1;
process.stdout.write(`ok ${passed} - ${name}\n`);
}
function assertToolError(callback, pattern) {
assert.throws(callback, (error) => error instanceof tool.FilterToolError && pattern.test(error.message));
}
function packet(overrides) {
return {
route: "unscoped_flood",
type: "grp_data",
hops: 4,
channel: "#rgdata",
path: "860C,12A4",
scopeStatus: "none",
scopeName: "",
regionName: "",
sender: "",
tempRadio: false,
snr: 6,
blacklist: false,
buckets: [],
loopLevel: 0,
...overrides,
};
}
test("builds and parses the BlackHole86 policy definition", () => {
const definition = "policy set blackhole-rewrite phase=rewrite owner=scope priority=160 when route=flood type=grp_data hops=4+ channel=#rgdata rx.scope=none path=prefix:860C do scope=#BlackHole86 timing=fast stop=phase";
const rule = tool.parseDefinition(definition);
assert.strictEqual(rule.targetKind, "scope");
assert.strictEqual(rule.target, "BlackHole86");
assert.strictEqual(tool.buildDefinition(rule), definition);
});
test("keeps a payload class and path bucket in one rule", () => {
const definition = "policy set other-bucket phase=rewrite owner=scope priority=130 when route=flood type=class:other hops=all path=bucket:2 do scope=#BlackHole86 timing=slow";
const rule = tool.parseDefinition(definition);
assert.strictEqual(rule.type, "class:other");
assert.strictEqual(rule.pathKind, "bucket:2");
assert.strictEqual(tool.buildDefinition(rule), definition);
assert.match(tool.explainRule(rule), /catch-all non-group, non-login class/);
});
test("enforces ACL 4 scope-manager boundaries", () => {
assertToolError(
() => tool.parseDefinition("policy set bad-drop phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do drop"),
/ACL 4 cannot create general drop/
);
assertToolError(
() => tool.parseDefinition("policy set bad-stop phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x stop=policy"),
/ACL 4 may stop only its current phase/
);
});
test("enforces ACL 5 filter-manager boundaries", () => {
assertToolError(
() => tool.parseDefinition("policy set bad-region phase=rewrite owner=filter priority=1 when route=flood type=any hops=all do region=usa"),
/ACL 5 cannot select a configured region target/
);
const rule = tool.parseDefinition(
"policy set public-scope phase=rewrite owner=filter priority=1 when route=flood type=any hops=all do scope=#BlackHole86"
);
assert.strictEqual(rule.targetKind, "scope");
});
test("limits decrypted sender matching to the content phase", () => {
assertToolError(
() => tool.parseDefinition('policy set early-sender phase=forward owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy User" do drop'),
/only in the content phase/
);
const definition = 'policy set noisy-user phase=content owner=filter priority=150 when route=flood type=grp_txt hops=all channel=public sender="Noisy User" do rate=5/min burst=5 tag=public-rate';
assert.strictEqual(tool.buildDefinition(tool.parseDefinition(definition)), definition);
const quoted = 'policy set quoted-user phase=content owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy \\"User\\"" do drop';
assert.strictEqual(tool.buildDefinition(tool.parseDefinition(quoted)), quoted);
});
test("round trips token rate and burst actions", () => {
const rule = tool.parseDefinition(
"policy set rate-limit phase=forward owner=filter priority=90 when route=flood type=grp_data hops=3+ channel=#rgdata do rate=10/min burst=3"
);
assert.strictEqual(rule.rate, 10);
assert.strictEqual(rule.burst, 3);
assert.strictEqual(tool.buildDefinition(rule).endsWith("rate=10/min burst=3"), true);
assertToolError(
() => tool.parseDefinition("policy set zero-rate phase=forward owner=filter priority=1 when route=flood type=any hops=all do rate=0/min"),
/Token rate must be 1-65534/
);
});
test("encodes and decodes a complete playground bundle", () => {
const rules = tool.EXAMPLES.mixed.map(tool.normalizeRule);
const bundle = tool.encodeBundle(rules);
assert.ok(bundle.startsWith(tool.BUNDLE_PREFIX));
assert.deepStrictEqual(tool.decodeBundle(bundle), tool.sortedRules(rules));
assert.deepStrictEqual(tool.parsePolicyInput(bundle), tool.sortedRules(rules));
});
test("imports JSON and multiple readable definitions", () => {
const rules = tool.EXAMPLES.blackhole.map(tool.normalizeRule);
const json = JSON.stringify(tool.policyDocument(rules));
assert.deepStrictEqual(tool.parsePolicyInput(json), rules);
const readable = [
"# a local note",
tool.buildDefinition(tool.EXAMPLES.blackhole[0]),
tool.buildDefinition(tool.EXAMPLES.system[0]),
].join("\n");
assert.strictEqual(tool.parsePolicyInput(readable).length, 2);
});
test("orders by phase, descending priority, and stable ASCII ID", () => {
const definitions = [
"policy set z-last phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=z",
"policy set rewrite-first phase=rewrite owner=scope priority=1 when route=flood type=any hops=all do scope=#x",
"policy set b-middle phase=forward owner=filter priority=30 when route=flood type=any hops=all do tag=b",
"policy set A-first phase=forward owner=filter priority=20 when route=flood type=any hops=all do tag=a",
];
assert.deepStrictEqual(
tool.sortedRules(definitions.map(tool.parseDefinition)).map((rule) => rule.id),
["rewrite-first", "b-middle", "A-first", "z-last"]
);
});
test("matches every rule against immutable receive-time scope", () => {
const rules = [
tool.parseDefinition("policy set rewrite phase=rewrite owner=scope priority=200 when route=flood type=grp_data hops=all rx.scope=none do scope=#BlackHole86"),
tool.parseDefinition("policy set original-none phase=forward owner=filter priority=100 when route=flood type=grp_data hops=all rx.scope=none do drop"),
];
const result = tool.simulatePolicy(rules, packet());
assert.deepStrictEqual(result.decision.scopeTarget, { kind: "scope", name: "BlackHole86", rule: "rewrite" });
assert.strictEqual(result.decision.drop, true);
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "match"]);
assert.strictEqual(result.packet.scopeStatus, "none");
});
test("stop=phase skips lower rules only in that phase", () => {
const rules = [
tool.parseDefinition("policy set first phase=forward owner=filter priority=200 when route=flood type=any hops=all do tag=first stop=phase"),
tool.parseDefinition("policy set skipped phase=forward owner=filter priority=100 when route=flood type=any hops=all do drop"),
tool.parseDefinition("policy set later phase=content owner=filter priority=100 when route=flood type=any hops=all do tag=later"),
];
const result = tool.simulatePolicy(rules, packet());
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "stopped", "match"]);
assert.deepStrictEqual(result.decision.tags, ["first", "later"]);
assert.strictEqual(result.decision.drop, false);
});
test("stop=policy skips every later configurable phase", () => {
const rules = [
tool.parseDefinition("policy set first phase=forward owner=admin priority=200 when route=flood type=any hops=all do tag=first stop=policy"),
tool.parseDefinition("policy set skipped phase=content owner=filter priority=100 when route=flood type=any hops=all do drop"),
];
const result = tool.simulatePolicy(rules, packet());
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["match", "stopped"]);
assert.strictEqual(result.decision.drop, false);
});
test("shadow rules neither act nor stop later rules", () => {
const rules = [
tool.parseDefinition("policy set shadow-drop phase=forward owner=admin priority=200 mode=shadow when route=flood type=any hops=all do drop stop=policy"),
tool.parseDefinition("policy set active-drop phase=forward owner=filter priority=100 when route=flood type=any hops=all do drop"),
];
const result = tool.simulatePolicy(rules, packet());
assert.deepStrictEqual(result.trace.map((entry) => entry.status), ["shadow", "match"]);
assert.strictEqual(result.decision.drop, true);
});
test("drop is sticky while later non-verdict actions still accumulate", () => {
const rules = [
tool.parseDefinition("policy set drop-first phase=forward owner=filter priority=200 when route=flood type=any hops=all do drop"),
tool.parseDefinition("policy set tag-later phase=forward owner=filter priority=100 when route=flood type=any hops=all do tag=observed"),
];
const result = tool.simulatePolicy(rules, packet());
assert.strictEqual(result.decision.drop, true);
assert.deepStrictEqual(result.decision.tags, ["observed"]);
});
test("scope-gate require drops an originally unscoped packet", () => {
const rule = tool.parseDefinition(
"policy set require-region phase=scope_gate owner=scope priority=200 when route=flood type=class:group hops=all do scope-gate=require stop=phase"
);
const result = tool.simulatePolicy([rule], packet());
assert.strictEqual(result.decision.scopeGate, "require_allowed");
assert.strictEqual(result.decision.drop, true);
assert.match(result.trace[0].detail, /scope requirement failed/);
});
test("matches login and future-safe other payload classes", () => {
const login = tool.parseDefinition("policy set login phase=forward owner=filter priority=1 when route=flood type=class:login hops=all do drop");
const other = tool.parseDefinition("policy set other phase=forward owner=filter priority=1 when route=flood type=class:other hops=all do drop");
assert.strictEqual(tool.matchRule(login, packet({ type: "req", channel: "" })).matched, true);
assert.strictEqual(tool.matchRule(login, packet({ type: "ota", channel: "" })).matched, false);
assert.strictEqual(tool.matchRule(other, packet({ type: "ota", channel: "" })).matched, true);
assert.strictEqual(tool.matchRule(other, packet({ type: "grp_data" })).matched, false);
assertToolError(() => tool.normalizePacket(packet({ type: "class:group", channel: "" })), /exact known type/);
assertToolError(() => tool.normalizePacket(packet({ type: "req" })), /authenticated channel/);
});
test("matches ordered pbyte prefixes, buckets, and loop thresholds", () => {
const prefix = tool.parseDefinition("policy set path-prefix phase=forward owner=filter priority=1 when route=flood type=any hops=all path=prefix:86,0C do drop");
const bucket = tool.parseDefinition("policy set path-bucket phase=forward owner=filter priority=1 when route=flood type=any hops=all path=bucket:2 do drop");
const moderate = tool.parseDefinition("policy set loop phase=forward owner=filter priority=1 when route=flood type=any hops=all path=loop:moderate do drop");
const facts = packet({ channel: "", path: "86,0C,AA", buckets: [2], loopLevel: 2 });
assert.strictEqual(tool.matchRule(prefix, facts).matched, true);
assert.strictEqual(tool.matchRule(bucket, facts).matched, true);
assert.strictEqual(tool.matchRule(moderate, facts).matched, true);
assertToolError(
() => tool.parseDefinition("policy set mixed-width phase=forward owner=filter priority=1 when route=flood type=any hops=all path=prefix:86,1234 do drop"),
/same 2, 4, or 6 hex-character width/
);
});
test("reports duplicate IDs, rewrite conflicts, and target overflow", () => {
const first = tool.parseDefinition("policy set duplicate phase=rewrite owner=scope priority=100 when route=flood type=any hops=all do scope=#one");
const second = tool.parseDefinition("policy set duplicate phase=rewrite owner=scope priority=100 when route=flood type=any hops=all do scope=#two");
const duplicates = tool.policyWarnings([first, second], "nrf52");
assert.ok(duplicates.some((warning) => /duplicated/.test(warning)));
assert.ok(duplicates.some((warning) => /overlapping core matches/.test(warning)));
const large = Array.from({ length: 90 }, (_unused, index) => tool.parseDefinition(
`policy set size-${index} phase=forward owner=filter priority=1 when route=flood type=any hops=all do tag=tag-${index}`
));
assert.ok(tool.policyWarnings(large, "stm32").some((warning) => /exceeds/.test(warning)));
});
test("flags lockout, future-type, spoofing, and policy-stop risks", () => {
const broad = tool.parseDefinition("policy set broad phase=forward owner=admin priority=255 when route=any type=any hops=all do drop stop=policy");
const other = tool.parseDefinition("policy set future phase=forward owner=filter priority=1 when route=flood type=class:other hops=all do drop");
const sender = tool.parseDefinition('policy set sender phase=content owner=filter priority=1 when route=flood type=grp_txt hops=all sender="Noisy User" do drop');
assert.ok(tool.ruleWarnings(broad).some((warning) => /remote administration/.test(warning)));
assert.ok(tool.ruleWarnings(broad).some((warning) => /mandatory protocol/.test(warning)));
assert.ok(tool.ruleWarnings(other).some((warning) => /future types/.test(warning)));
assert.ok(tool.ruleWarnings(sender).some((warning) => /spoofable/.test(warning)));
});
process.stdout.write(`# ${passed} filter-policy playground tests passed\n`);