instar 1.3.1176 → 1.3.1177
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/dashboard/index.html +263 -5
- package/dashboard/money-layer-enable.js +293 -0
- package/dashboard/spend-arming.js +25 -0
- package/dashboard/subscriptions.js +56 -5
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +17 -1
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +15 -3
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/upgrades/1.3.1177.md +140 -0
- package/upgrades/side-effects/money-layer-enable-ui-and-provider-grouping.md +188 -0
package/dashboard/index.html
CHANGED
|
@@ -3540,11 +3540,22 @@
|
|
|
3540
3540
|
<div class="tab-purpose">
|
|
3541
3541
|
What the internal AI calls cost, and where the paid-door caps sit. The dollar figures are a
|
|
3542
3542
|
<b>reporting</b> view — immutable token counts priced against a reviewed price list on read, so a
|
|
3543
|
-
price correction reflows automatically. <b>
|
|
3544
|
-
subscription/CLI doors are <b>$0 (not per-token billed)</b>.
|
|
3545
|
-
the controls below set a door's ceilings and take it live,
|
|
3543
|
+
price correction reflows automatically. <b>A paid door spends nothing until you arm it below</b>,
|
|
3544
|
+
so metered spend stays $0 until then; subscription/CLI doors are <b>$0 (not per-token billed)</b>.
|
|
3545
|
+
The spend figures above are a report; the controls below set a door's ceilings and take it live,
|
|
3546
|
+
and each of those takes your PIN.
|
|
3547
|
+
<!-- This used to state flatly "No paid door is live yet". That was true only while no operator
|
|
3548
|
+
could arm one; it becomes FALSE the moment they use the controls directly beneath it, and a
|
|
3549
|
+
false statement on the money screen is worse than a vague one. The per-door state is
|
|
3550
|
+
rendered live below and is the authority. -->
|
|
3546
3551
|
</div>
|
|
3547
3552
|
|
|
3553
|
+
<!-- The operator's ON SWITCH (money-layer-operator-enable-surface.md, Phase 2 UI).
|
|
3554
|
+
FIRST on the tab and rendered from the PRE-GATE routes, which answer while the
|
|
3555
|
+
layer is off — everything below it is gated on this switch, so when it is off
|
|
3556
|
+
the rest of the tab can only 503 and would otherwise read as broken. -->
|
|
3557
|
+
<div id="moneyLayerEnable" class="spend-arm-root"></div>
|
|
3558
|
+
|
|
3548
3559
|
<!-- Glance floors F10/F11 (topic 29836, Phase 3): the shared component renders
|
|
3549
3560
|
the headline + totals tiles + drill-down here. "Metered / paid door / reflows"
|
|
3550
3561
|
became plain words at the glance ("pay-per-use"); the per-model math and the
|
|
@@ -3660,8 +3671,27 @@
|
|
|
3660
3671
|
.spend-arm-plan-text { font-size:14px; line-height:1.5; padding:12px; border-radius:8px;
|
|
3661
3672
|
border:1px solid var(--border,#2a2a2a); white-space:pre-wrap; word-break:break-word; }
|
|
3662
3673
|
.spend-arm-plan-expiry, .spend-arm-note { font-size:12px; opacity:.7; }
|
|
3674
|
+
.spend-arm-status { font-size:13px; opacity:.85; line-height:1.5; }
|
|
3675
|
+
.spend-arm-pin-row { display:flex; flex-direction:column; gap:8px; margin-top:10px; }
|
|
3676
|
+
.spend-arm-pin { width:100%; box-sizing:border-box; min-height:44px; padding:10px 12px;
|
|
3677
|
+
font-size:16px; border:1px solid var(--border,#2a2a2a); border-radius:8px;
|
|
3678
|
+
background:transparent; color:inherit; }
|
|
3663
3679
|
@media (max-width:520px) { .spend-arm-btn { flex:1 1 100%; } }
|
|
3664
3680
|
|
|
3681
|
+
/* The operator's ON SWITCH for the spending controls. Colour carries the ONE fact
|
|
3682
|
+
that matters — enforcing vs not — and never implies that spending is armed. */
|
|
3683
|
+
.mle-state { border:1px solid var(--border,#2a2a2a); border-left-width:4px; border-radius:8px;
|
|
3684
|
+
padding:12px 14px; display:flex; flex-direction:column; gap:6px; }
|
|
3685
|
+
.mle-state-enforcing { border-left-color:#16a34a; }
|
|
3686
|
+
.mle-state-pending-restart { border-left-color:#eab308; }
|
|
3687
|
+
.mle-state-failed { border-left-color:#dc2626; }
|
|
3688
|
+
.mle-state-off { border-left-color:var(--border,#2a2a2a); }
|
|
3689
|
+
.mle-headline { font-size:15px; font-weight:600; }
|
|
3690
|
+
.mle-detail { font-size:13px; opacity:.8; line-height:1.5; }
|
|
3691
|
+
.mle-config-notice { font-size:13px; line-height:1.5; color:#f59e0b; }
|
|
3692
|
+
.mle-machine { font-size:12px; opacity:.6; }
|
|
3693
|
+
.mle-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
|
3694
|
+
|
|
3665
3695
|
/* Provider group heading — only rendered when more than one provider is
|
|
3666
3696
|
enrolled, so a single-provider install looks exactly as it did before. */
|
|
3667
3697
|
.sub-provider-heading { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
|
|
@@ -3695,6 +3725,10 @@
|
|
|
3695
3725
|
#subMatrix { width:100%; min-width:0; overflow-x:auto; }
|
|
3696
3726
|
.sub-matrix th, .sub-matrix td { border:1px solid var(--border,#2a2a2a); padding:8px 10px; text-align:center; vertical-align:top; }
|
|
3697
3727
|
.sub-matrix-acct { text-align:left; font-weight:600; word-break:break-all; }
|
|
3728
|
+
/* Provider band inside the grid — same rule as the cards above: it exists only when
|
|
3729
|
+
more than one provider is enrolled, so a single-provider install is unchanged. */
|
|
3730
|
+
.sub-matrix-group { text-align:left; font-size:11px; font-weight:700; letter-spacing:.06em;
|
|
3731
|
+
text-transform:uppercase; opacity:.75; background:var(--panel-alt,rgba(255,255,255,.04)); }
|
|
3698
3732
|
.sub-matrix-mach { font-weight:600; }
|
|
3699
3733
|
.sub-matrix-off { opacity:.55; }
|
|
3700
3734
|
.sub-matrix-cell.sub-matrix-offline { opacity:.5; }
|
|
@@ -6014,6 +6048,12 @@
|
|
|
6014
6048
|
if (!glanceRoot) return;
|
|
6015
6049
|
const grain = (document.getElementById('spendGrain') || {}).value || 'day';
|
|
6016
6050
|
|
|
6051
|
+
// The switch renders FIRST and independently: its routes answer while the layer is
|
|
6052
|
+
// off, and every fetch below is gated on the layer being on. Rendering it after the
|
|
6053
|
+
// gated calls would mean the one control that can fix a 503 disappears whenever the
|
|
6054
|
+
// 503 happens — which is exactly the state the operator is in when they need it.
|
|
6055
|
+
loadMoneyLayerEnable();
|
|
6056
|
+
|
|
6017
6057
|
const glance = await loadGlanceModule();
|
|
6018
6058
|
if (!glance) { glanceRoot.textContent = 'Loading the glance view failed — refresh to retry.'; return; }
|
|
6019
6059
|
|
|
@@ -8667,6 +8707,210 @@
|
|
|
8667
8707
|
return __glanceModule;
|
|
8668
8708
|
}
|
|
8669
8709
|
|
|
8710
|
+
// ── Spend tab — the operator's ON SWITCH (money-layer enable, Phase 2 UI) ──
|
|
8711
|
+
// The six pre-gate routes answer 200 while the money layer is OFF; that is what makes
|
|
8712
|
+
// this panel possible at all. Flow: read status → plan (Bearer, no PIN) → the SERVER's
|
|
8713
|
+
// words shown verbatim → PIN commit → if it is now switched on but not yet built, the
|
|
8714
|
+
// restart step → poll status until it reports enforcing.
|
|
8715
|
+
let __mleModule = null;
|
|
8716
|
+
async function loadMleModule() {
|
|
8717
|
+
if (!__mleModule) {
|
|
8718
|
+
try { __mleModule = await import('/dashboard/money-layer-enable.js'); }
|
|
8719
|
+
catch (e) { console.error('[money-layer-enable] module load failed', e); return null; }
|
|
8720
|
+
}
|
|
8721
|
+
return __mleModule;
|
|
8722
|
+
}
|
|
8723
|
+
|
|
8724
|
+
// The plan (or restart nonce) currently awaiting the operator's PIN. Cleared on every
|
|
8725
|
+
// re-render so a PIN can never be applied to an approval that has since gone stale.
|
|
8726
|
+
let __mlePendingPlan = null;
|
|
8727
|
+
let __mlePendingRestart = null;
|
|
8728
|
+
|
|
8729
|
+
function setMleStatus(text) {
|
|
8730
|
+
const n = document.getElementById('mleStatus');
|
|
8731
|
+
if (n) n.textContent = text || '';
|
|
8732
|
+
}
|
|
8733
|
+
|
|
8734
|
+
async function loadMoneyLayerEnable() {
|
|
8735
|
+
const root = document.getElementById('moneyLayerEnable');
|
|
8736
|
+
if (!root) return;
|
|
8737
|
+
const M = await loadMleModule();
|
|
8738
|
+
if (!M) { root.textContent = 'Loading the spending-control switch failed — refresh to retry.'; return; }
|
|
8739
|
+
|
|
8740
|
+
let status = null;
|
|
8741
|
+
try {
|
|
8742
|
+
status = await apiFetch('/routing-spend/enable-status');
|
|
8743
|
+
} catch (err) {
|
|
8744
|
+
root.replaceChildren();
|
|
8745
|
+
const note = document.createElement('div');
|
|
8746
|
+
note.className = 'spend-arm-note';
|
|
8747
|
+
note.textContent = M.enableNote(err && err.message);
|
|
8748
|
+
root.appendChild(note);
|
|
8749
|
+
return;
|
|
8750
|
+
}
|
|
8751
|
+
|
|
8752
|
+
__mlePendingPlan = null;
|
|
8753
|
+
__mlePendingRestart = null;
|
|
8754
|
+
root.replaceChildren();
|
|
8755
|
+
|
|
8756
|
+
const h = document.createElement('h3');
|
|
8757
|
+
h.className = 'spend-arm-h';
|
|
8758
|
+
h.textContent = 'Spending controls';
|
|
8759
|
+
root.appendChild(h);
|
|
8760
|
+
|
|
8761
|
+
const panel = document.createElement('div');
|
|
8762
|
+
root.appendChild(panel);
|
|
8763
|
+
M.renderEnablePanel(document, panel, status, { onAction: (action) => onMleAction(action) });
|
|
8764
|
+
|
|
8765
|
+
const approval = document.createElement('div');
|
|
8766
|
+
approval.id = 'mleApproval';
|
|
8767
|
+
root.appendChild(approval);
|
|
8768
|
+
|
|
8769
|
+
const st = document.createElement('div');
|
|
8770
|
+
st.id = 'mleStatus';
|
|
8771
|
+
st.className = 'spend-arm-status';
|
|
8772
|
+
root.appendChild(st);
|
|
8773
|
+
}
|
|
8774
|
+
|
|
8775
|
+
/** Render the PIN box + Approve button under whichever approval is pending. */
|
|
8776
|
+
function mleShowPinBox(label, onApprove) {
|
|
8777
|
+
const holder = document.getElementById('mleApproval');
|
|
8778
|
+
if (!holder) return;
|
|
8779
|
+
const row = document.createElement('div');
|
|
8780
|
+
row.className = 'spend-arm-pin-row';
|
|
8781
|
+
const pin = document.createElement('input');
|
|
8782
|
+
pin.type = 'password';
|
|
8783
|
+
pin.inputMode = 'numeric';
|
|
8784
|
+
pin.id = 'mlePin';
|
|
8785
|
+
pin.className = 'spend-arm-pin';
|
|
8786
|
+
pin.placeholder = 'Dashboard PIN';
|
|
8787
|
+
pin.autocomplete = 'off';
|
|
8788
|
+
const go = document.createElement('button');
|
|
8789
|
+
go.className = 'spend-arm-btn spend-arm-btn-commit';
|
|
8790
|
+
go.textContent = label;
|
|
8791
|
+
go.addEventListener('click', () => onApprove((pin.value || '').trim()));
|
|
8792
|
+
row.appendChild(pin);
|
|
8793
|
+
row.appendChild(go);
|
|
8794
|
+
holder.appendChild(row);
|
|
8795
|
+
}
|
|
8796
|
+
|
|
8797
|
+
async function onMleAction(action) {
|
|
8798
|
+
const M = await loadMleModule();
|
|
8799
|
+
if (!M) return;
|
|
8800
|
+
setMleStatus('');
|
|
8801
|
+
if (action === '__restart') { return mleStartRestart(); }
|
|
8802
|
+
const holder = document.getElementById('mleApproval');
|
|
8803
|
+
if (!holder) return;
|
|
8804
|
+
try {
|
|
8805
|
+
const plan = await apiFetch('/routing-spend/plan-money-layer', {
|
|
8806
|
+
method: 'POST',
|
|
8807
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8808
|
+
body: JSON.stringify(M.planRequest(action)),
|
|
8809
|
+
});
|
|
8810
|
+
__mlePendingPlan = plan;
|
|
8811
|
+
__mlePendingRestart = null;
|
|
8812
|
+
// The SERVER's words, verbatim — including the case where it substituted the
|
|
8813
|
+
// acknowledged "this will not stop spending" variant for a plain disable.
|
|
8814
|
+
M.renderPlanApproval(document, holder, plan);
|
|
8815
|
+
mleShowPinBox('Approve with PIN', (pinValue) => mleCommit(pinValue));
|
|
8816
|
+
} catch (err) {
|
|
8817
|
+
holder.replaceChildren();
|
|
8818
|
+
setMleStatus(M.enableNote(err && err.message));
|
|
8819
|
+
}
|
|
8820
|
+
}
|
|
8821
|
+
|
|
8822
|
+
async function mleCommit(pinValue) {
|
|
8823
|
+
const M = await loadMleModule();
|
|
8824
|
+
if (!M || !__mlePendingPlan) { setMleStatus('Preview a change first.'); return; }
|
|
8825
|
+
if (!pinValue) { setMleStatus('Enter your dashboard PIN to approve.'); return; }
|
|
8826
|
+
try {
|
|
8827
|
+
const out = await apiFetch('/routing-spend/money-layer/commit', {
|
|
8828
|
+
method: 'POST',
|
|
8829
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8830
|
+
body: JSON.stringify(M.commitRequest(__mlePendingPlan, pinValue)),
|
|
8831
|
+
});
|
|
8832
|
+
__mlePendingPlan = null;
|
|
8833
|
+
// The server's own message is the report — it is the only thing that knows whether
|
|
8834
|
+
// this enabled, half-enabled, or cleared a flag that did not stop spending.
|
|
8835
|
+
setMleStatus(out && out.message ? String(out.message) : 'Applied.');
|
|
8836
|
+
const next = M.nextStepAfterCommit(out);
|
|
8837
|
+
await loadMoneyLayerEnable();
|
|
8838
|
+
setMleStatus(out && out.message ? String(out.message) : 'Applied.');
|
|
8839
|
+
if (next === 'restart') mleStartRestart();
|
|
8840
|
+
} catch (err) {
|
|
8841
|
+
setMleStatus(M.enableNote(err && err.message));
|
|
8842
|
+
}
|
|
8843
|
+
}
|
|
8844
|
+
|
|
8845
|
+
async function mleStartRestart() {
|
|
8846
|
+
const M = await loadMleModule();
|
|
8847
|
+
if (!M) return;
|
|
8848
|
+
const holder = document.getElementById('mleApproval');
|
|
8849
|
+
if (!holder) return;
|
|
8850
|
+
try {
|
|
8851
|
+
const mint = await apiFetch('/routing-spend/money-layer/restart-nonce', { method: 'POST' });
|
|
8852
|
+
__mlePendingRestart = mint;
|
|
8853
|
+
__mlePendingPlan = null;
|
|
8854
|
+
M.renderRestartConfirmation(document, holder, mint);
|
|
8855
|
+
mleShowPinBox('Restart with PIN', (pinValue) => mleCommitRestart(pinValue));
|
|
8856
|
+
} catch (err) {
|
|
8857
|
+
holder.replaceChildren();
|
|
8858
|
+
setMleStatus(M.enableNote(err && err.message));
|
|
8859
|
+
}
|
|
8860
|
+
}
|
|
8861
|
+
|
|
8862
|
+
async function mleCommitRestart(pinValue) {
|
|
8863
|
+
const M = await loadMleModule();
|
|
8864
|
+
if (!M || !__mlePendingRestart) { setMleStatus('Start the restart step first.'); return; }
|
|
8865
|
+
if (!pinValue) { setMleStatus('Enter your dashboard PIN to approve.'); return; }
|
|
8866
|
+
let hash;
|
|
8867
|
+
try {
|
|
8868
|
+
// The hash is taken over the text that was DISPLAYED, which is what binds the
|
|
8869
|
+
// restart to words the operator actually saw.
|
|
8870
|
+
hash = await M.sha256Hex(__mlePendingRestart.confirmationText);
|
|
8871
|
+
} catch {
|
|
8872
|
+
setMleStatus('This page can’t approve a restart over a plain http address — open the ' +
|
|
8873
|
+
'dashboard on its https link (or on this machine directly) and try again. Nothing has changed.');
|
|
8874
|
+
return;
|
|
8875
|
+
}
|
|
8876
|
+
try {
|
|
8877
|
+
const out = await apiFetch('/routing-spend/money-layer/restart', {
|
|
8878
|
+
method: 'POST',
|
|
8879
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8880
|
+
body: JSON.stringify(M.restartRequest(__mlePendingRestart.nonce, hash, pinValue)),
|
|
8881
|
+
});
|
|
8882
|
+
__mlePendingRestart = null;
|
|
8883
|
+
const holder = document.getElementById('mleApproval');
|
|
8884
|
+
if (holder) holder.replaceChildren();
|
|
8885
|
+
setMleStatus(out && out.message ? String(out.message) : 'Restart requested.');
|
|
8886
|
+
// The restart response cannot observe the NEW process, so the panel keeps checking
|
|
8887
|
+
// until the status it reads says the controls are enforcing.
|
|
8888
|
+
mlePollAfterRestart(0);
|
|
8889
|
+
} catch (err) {
|
|
8890
|
+
setMleStatus(M.enableNote(err && err.message));
|
|
8891
|
+
}
|
|
8892
|
+
}
|
|
8893
|
+
|
|
8894
|
+
function mlePollAfterRestart(attempt) {
|
|
8895
|
+
if (attempt > 20) {
|
|
8896
|
+
setMleStatus('The server has not come back yet. Refresh this tab in a moment to see ' +
|
|
8897
|
+
'whether the spending controls are enforcing.');
|
|
8898
|
+
return;
|
|
8899
|
+
}
|
|
8900
|
+
setTimeout(async () => {
|
|
8901
|
+
let status = null;
|
|
8902
|
+
try { status = await apiFetch('/routing-spend/enable-status'); }
|
|
8903
|
+
catch { return mlePollAfterRestart(attempt + 1); } /* @silent-fallback-ok — the server is restarting; keep waiting */
|
|
8904
|
+
await loadMoneyLayerEnable();
|
|
8905
|
+
if (status && status.enforcementReady === true) {
|
|
8906
|
+
setMleStatus('Spending controls are up and enforcing. Nothing is armed yet — arm a door below.');
|
|
8907
|
+
loadRoutingSpend();
|
|
8908
|
+
} else {
|
|
8909
|
+
mlePollAfterRestart(attempt + 1);
|
|
8910
|
+
}
|
|
8911
|
+
}, 3000);
|
|
8912
|
+
}
|
|
8913
|
+
|
|
8670
8914
|
// ── Spend tab — paid-door arming (Increment B UI) ──
|
|
8671
8915
|
// Two PIN-committed steps (set both ceilings, then go live), each previewed as the
|
|
8672
8916
|
// SERVER's rendered wording before the PIN box appears. The commit posts only the
|
|
@@ -8750,6 +8994,10 @@
|
|
|
8750
8994
|
btns.appendChild(mkBtn('Preview cap change', 'spend-arm-btn', () => previewSpendPlan('caps-adjust')));
|
|
8751
8995
|
btns.appendChild(mkBtn('Preview go live', 'spend-arm-btn', () => previewSpendPlan('go-live')));
|
|
8752
8996
|
btns.appendChild(mkBtn('Freeze this door', 'spend-arm-btn spend-arm-btn-freeze', freezeSpendDoor));
|
|
8997
|
+
// Freeze is always available and needs no PIN — so its REVERSE has to be reachable from
|
|
8998
|
+
// the same screen, or the operator can stop spending here and has nowhere to resume it.
|
|
8999
|
+
// Unfreeze is PIN-gated on purpose: releasing money is the operator's, halting it is cheap.
|
|
9000
|
+
btns.appendChild(mkBtn('Unfreeze this door', 'spend-arm-btn', () => previewSpendPlan('unfreeze')));
|
|
8753
9001
|
root.appendChild(btns);
|
|
8754
9002
|
|
|
8755
9003
|
const planBox = document.createElement('div');
|
|
@@ -8804,7 +9052,12 @@
|
|
|
8804
9052
|
if (!row) { setSpendArmStatus('Pick a door first.'); return; }
|
|
8805
9053
|
|
|
8806
9054
|
let fields;
|
|
8807
|
-
if (action === '
|
|
9055
|
+
if (action === 'unfreeze') {
|
|
9056
|
+
// Say so here rather than posting a plan the server would refuse: "that door isn't
|
|
9057
|
+
// frozen" is an answer; a 400 from the commit is a puzzle.
|
|
9058
|
+
if (!row.frozen) { setSpendArmStatus('That door isn’t frozen — there is nothing to release.'); return; }
|
|
9059
|
+
fields = { keyRef: row.keyRef };
|
|
9060
|
+
} else if (action === 'caps-adjust') {
|
|
8808
9061
|
const num = (id) => {
|
|
8809
9062
|
const raw = (document.getElementById(id) || {}).value;
|
|
8810
9063
|
return raw === '' || raw === undefined ? '' : Number(raw);
|
|
@@ -8840,8 +9093,13 @@
|
|
|
8840
9093
|
const pinEl = document.getElementById('spendArmPin');
|
|
8841
9094
|
const pin = pinEl ? pinEl.value : '';
|
|
8842
9095
|
if (!pin) { setSpendArmStatus('Enter your dashboard PIN to approve.'); return; }
|
|
9096
|
+
// Each action has its OWN commit route and each refuses a plan signed for a different
|
|
9097
|
+
// one. Posting everything to caps/adjust made "go live" — arming a door, the point of
|
|
9098
|
+
// this panel — fail with a 400 after the operator had already entered their PIN.
|
|
9099
|
+
const route = M.commitRoute(__spendPendingPlan);
|
|
9100
|
+
if (!route) { setSpendArmStatus('That approval is no longer valid. Preview the change again.'); return; }
|
|
8843
9101
|
try {
|
|
8844
|
-
await apiFetch(
|
|
9102
|
+
await apiFetch(route, {
|
|
8845
9103
|
method: 'POST',
|
|
8846
9104
|
headers: { 'Content-Type': 'application/json' },
|
|
8847
9105
|
body: JSON.stringify(M.commitRequest(__spendPendingPlan, pin)),
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend tab — the operator's ON SWITCH for the spending-control layer (Phase 2 UI for
|
|
3
|
+
* docs/specs/money-layer-operator-enable-surface.md).
|
|
4
|
+
*
|
|
5
|
+
* Phase 1 built six pre-gate routes and no screen. The operator's report was the same
|
|
6
|
+
* sentence twice: *"still has no path/mechanism to enable any options."* The arming
|
|
7
|
+
* controls next to this panel sit BEHIND this switch, so until there is a button here,
|
|
8
|
+
* every one of them answers 503 and the tab reads as broken rather than as off.
|
|
9
|
+
*
|
|
10
|
+
* Four things this module refuses to do, because each one would re-create a failure the
|
|
11
|
+
* spec is built around:
|
|
12
|
+
*
|
|
13
|
+
* 1. It never says "spending works". Enabling arms NO door — every paid door stays
|
|
14
|
+
* refused at $0 until it is separately armed. The copy says "spending controls are up
|
|
15
|
+
* and enforcing" and nothing stronger.
|
|
16
|
+
* 2. It never claims enabling took effect immediately. The layer is CONSTRUCTED at server
|
|
17
|
+
* start, so a commit answers `enable-pending-restart` and this panel says so and offers
|
|
18
|
+
* the restart, rather than showing a switch that reads on over machinery that is down.
|
|
19
|
+
* 3. It never paraphrases the server's words. The plan text and the restart confirmation
|
|
20
|
+
* text are displayed VERBATIM — a client that paraphrased could show a reassuring label
|
|
21
|
+
* over a different signed action.
|
|
22
|
+
* 4. It never presents a store-only disable as "stopped". When the config file is what
|
|
23
|
+
* enables the layer, clearing the operator flag does NOT stop spending, and this panel
|
|
24
|
+
* leads with freeze instead of a disable that would quietly do nothing.
|
|
25
|
+
*
|
|
26
|
+
* A lifecycle enum value is never rendered — the operator sees copy derived from
|
|
27
|
+
* `enforcementReady` and `enableSources`, never `probed` or `enable-pending-restart` raw.
|
|
28
|
+
*
|
|
29
|
+
* Every dynamic value is written with textContent (same contract as subscriptions.js).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Element helper — textContent ONLY, never innerHTML. */
|
|
33
|
+
function el(doc, tag, cls, text) {
|
|
34
|
+
const n = doc.createElement(tag);
|
|
35
|
+
if (cls) n.className = cls;
|
|
36
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What to tell the operator when a pre-gate call fails.
|
|
42
|
+
*
|
|
43
|
+
* These six routes answer 200 while the layer is OFF — that is their whole purpose — so a
|
|
44
|
+
* 503 here means something quite different from a 503 on the arming controls: the enable
|
|
45
|
+
* SURFACE itself did not construct. Saying "your switch is off" there would send the
|
|
46
|
+
* operator to flip a switch that is not the problem.
|
|
47
|
+
*/
|
|
48
|
+
export function enableNote(message) {
|
|
49
|
+
const m = message === undefined || message === null ? '' : String(message);
|
|
50
|
+
if (/bad-pin|pin/i.test(m)) {
|
|
51
|
+
return 'That PIN was not accepted. Nothing has changed.';
|
|
52
|
+
}
|
|
53
|
+
if (/lock-not-held/i.test(m)) {
|
|
54
|
+
return 'Another copy of the agent server owns this machine right now, so this one will ' +
|
|
55
|
+
'not act on money settings. Nothing has changed.';
|
|
56
|
+
}
|
|
57
|
+
if (/stale-nonce|confirmation-hash-mismatch|expired/i.test(m)) {
|
|
58
|
+
return 'That approval went stale before it was used. Nothing has changed — start it again.';
|
|
59
|
+
}
|
|
60
|
+
if (/nothing-to-mirror/i.test(m)) {
|
|
61
|
+
return 'There is nothing to copy — the config file is not switching this on.';
|
|
62
|
+
}
|
|
63
|
+
if (/rate|too many/i.test(m)) {
|
|
64
|
+
return 'That was too soon after the last attempt. Nothing has changed — wait a moment ' +
|
|
65
|
+
'and try again.';
|
|
66
|
+
}
|
|
67
|
+
if (/503|surface-unavailable|surface-error/i.test(m)) {
|
|
68
|
+
return 'The spending-control settings could not be read on this machine, so nothing here ' +
|
|
69
|
+
'will act rather than guess. Nothing has changed.';
|
|
70
|
+
}
|
|
71
|
+
return 'Could not reach the spending-control settings just now. Nothing has changed — try again.';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The headline state, in the operator's words.
|
|
76
|
+
*
|
|
77
|
+
* `enforcementReady` is the ONE fact that means the controls are live; everything else is
|
|
78
|
+
* a reason they are not. The three off-reasons are kept distinct because the remedy
|
|
79
|
+
* differs: off (turn it on), asked-for-but-not-built (restart), and built-but-refusing
|
|
80
|
+
* (restart, and say which part refused).
|
|
81
|
+
*/
|
|
82
|
+
export function enableHeadline(status) {
|
|
83
|
+
const s = status || {};
|
|
84
|
+
if (s.enforcementReady === true) {
|
|
85
|
+
return {
|
|
86
|
+
state: 'enforcing',
|
|
87
|
+
headline: 'Spending controls are up and enforcing',
|
|
88
|
+
// The one sentence that stops "on" being read as "spend works".
|
|
89
|
+
detail: 'Turning them on arms nothing by itself — every paid service stays refused, at $0, ' +
|
|
90
|
+
'until you arm it below.',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const lifecycle = typeof s.lifecycleState === 'string' ? s.lifecycleState : '';
|
|
94
|
+
if (lifecycle === 'enable-pending-restart') {
|
|
95
|
+
return {
|
|
96
|
+
state: 'pending-restart',
|
|
97
|
+
headline: 'Switched on — waiting for a restart to take effect',
|
|
98
|
+
detail: 'The spending controls are built when the agent server starts, so they are not ' +
|
|
99
|
+
'enforcing yet. Restarting this machine’s agent server finishes it.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (lifecycle === 'construction-failed' || lifecycle === 'probe-failed') {
|
|
103
|
+
const part = typeof s.failingComponent === 'string' && s.failingComponent ? s.failingComponent : '';
|
|
104
|
+
return {
|
|
105
|
+
state: 'failed',
|
|
106
|
+
headline: 'Switched on, but the controls are NOT enforcing',
|
|
107
|
+
detail: (part ? `The part that refused is: ${part}. ` : '') +
|
|
108
|
+
'Nothing can spend in this state — the paid path refuses when the controls are not up. ' +
|
|
109
|
+
'A restart of this machine’s agent server is the remedy.',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
state: 'off',
|
|
114
|
+
headline: 'Spending controls are off',
|
|
115
|
+
detail: 'While they are off, the caps and go-live controls below cannot act. Turning them ' +
|
|
116
|
+
'on is your decision — it is deliberately not something an agent can flip.',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The notice for an enable that came from the CONFIG FILE rather than from this screen.
|
|
122
|
+
*
|
|
123
|
+
* This is the trap in the whole feature: pressing "turn off" here clears the operator flag
|
|
124
|
+
* only. No route writes the config file, so with `config-enabled` the layer stays on and a
|
|
125
|
+
* disable that looked successful would not have stopped anything. Freeze is the control
|
|
126
|
+
* that actually halts money, so the notice leads with it.
|
|
127
|
+
*/
|
|
128
|
+
export function configSourceNotice(status) {
|
|
129
|
+
const state = (status && status.enableSources && status.enableSources.state) || '';
|
|
130
|
+
if (state === 'config-enabled') {
|
|
131
|
+
return 'This is switched on by the config file on this machine, not from here. Turning it ' +
|
|
132
|
+
'off here clears only my setting — it will NOT stop spending. To stop spending now, ' +
|
|
133
|
+
'freeze the door below; freezing is instant and always available.';
|
|
134
|
+
}
|
|
135
|
+
if (state === 'both-enabled') {
|
|
136
|
+
return 'This is switched on both here and in the config file on this machine. Turning it ' +
|
|
137
|
+
'off here clears only my setting; the config file would keep it on.';
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Which actions this state genuinely offers. Buttons are derived from state rather than
|
|
144
|
+
* always rendered and conditionally refused — an inert button is the same "where do I tap?"
|
|
145
|
+
* failure this screen exists to remove.
|
|
146
|
+
*/
|
|
147
|
+
export function availableActions(status) {
|
|
148
|
+
const s = status || {};
|
|
149
|
+
const sources = s.enableSources || {};
|
|
150
|
+
const out = [];
|
|
151
|
+
if (s.enforcementReady !== true) {
|
|
152
|
+
// Offered in EVERY not-enforcing state, including one already switched on: "switch says
|
|
153
|
+
// on, machinery down" is exactly the state this control exists to rescue, so re-pressing
|
|
154
|
+
// it must re-verify rather than be treated as a no-op.
|
|
155
|
+
out.push({ action: 'money-layer-enable', label: sources.store === true ? 'Re-check and turn on' : 'Turn on spending controls' });
|
|
156
|
+
}
|
|
157
|
+
if (sources.config === true && sources.store !== true) {
|
|
158
|
+
out.push({ action: 'money-layer-mirror-config', label: 'Also record it here' });
|
|
159
|
+
}
|
|
160
|
+
if (sources.store === true) {
|
|
161
|
+
out.push({ action: 'money-layer-disable', label: 'Turn off' });
|
|
162
|
+
}
|
|
163
|
+
if (s.restartEligible === true) {
|
|
164
|
+
out.push({ action: '__restart', label: 'Restart the agent server' });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The plan request. Bearer-only and deliberately PIN-free: the operator must see the
|
|
170
|
+
* server's words BEFORE any secret leaves the page. */
|
|
171
|
+
export function planRequest(action) {
|
|
172
|
+
return { action };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The commit body — the plan identity plus the PIN, never a form field. The server
|
|
176
|
+
* derives what to apply solely from the plan it rendered. */
|
|
177
|
+
export function commitRequest(plan, pin) {
|
|
178
|
+
return { planId: (plan || {}).planId, nonce: (plan || {}).nonce, pin };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The restart body. The hash binds the restart to the exact confirmation text that was
|
|
182
|
+
* DISPLAYED, so a restart cannot be approved against words the operator never saw. */
|
|
183
|
+
export function restartRequest(nonce, confirmationTextHash, pin) {
|
|
184
|
+
return { nonce, confirmationTextHash, pin };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* SHA-256 of the confirmation text, hex.
|
|
189
|
+
*
|
|
190
|
+
* The server mints the text and its hash but returns only the TEXT, so the client hashes
|
|
191
|
+
* what it displayed — that is what makes the binding meaningful. `crypto.subtle` exists on
|
|
192
|
+
* https and on localhost; over plain http to a LAN address it does not, and the honest
|
|
193
|
+
* answer there is to say the restart cannot be approved from this page rather than to
|
|
194
|
+
* silently send an unbound request.
|
|
195
|
+
*/
|
|
196
|
+
export async function sha256Hex(text) {
|
|
197
|
+
const subtle = (globalThis.crypto && globalThis.crypto.subtle) || null;
|
|
198
|
+
if (!subtle) {
|
|
199
|
+
throw new Error('secure-context-required');
|
|
200
|
+
}
|
|
201
|
+
const bytes = new TextEncoder().encode(String(text));
|
|
202
|
+
const digest = await subtle.digest('SHA-256', bytes);
|
|
203
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Render the server's plan text for approval — verbatim, never paraphrased. */
|
|
207
|
+
export function renderPlanApproval(doc, target, plan) {
|
|
208
|
+
if (!target) return;
|
|
209
|
+
target.replaceChildren();
|
|
210
|
+
if (!plan || !plan.renderedText) {
|
|
211
|
+
target.appendChild(el(doc, 'div', 'spend-arm-note', 'Nothing to approve yet.'));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-label', 'Approve exactly this:'));
|
|
215
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-text', plan.renderedText));
|
|
216
|
+
if (plan.expiresAt) {
|
|
217
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-expiry', `This approval expires at ${plan.expiresAt}.`));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render the restart confirmation — the server's words, verbatim, plus the one fact the
|
|
223
|
+
* server's text cannot know the operator is missing: this restarts the WHOLE agent server
|
|
224
|
+
* on this machine, not just the spending controls.
|
|
225
|
+
*/
|
|
226
|
+
export function renderRestartConfirmation(doc, target, mint) {
|
|
227
|
+
if (!target) return;
|
|
228
|
+
target.replaceChildren();
|
|
229
|
+
if (!mint || !mint.confirmationText) {
|
|
230
|
+
target.appendChild(el(doc, 'div', 'spend-arm-note', 'Nothing to approve yet.'));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-label', 'Approve exactly this:'));
|
|
234
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-text', mint.confirmationText));
|
|
235
|
+
target.appendChild(el(doc, 'div', 'spend-arm-note',
|
|
236
|
+
'This restarts the whole agent server on this machine, not only the spending controls. ' +
|
|
237
|
+
'It comes back on its own; anything mid-flight is interrupted.'));
|
|
238
|
+
if (mint.expiresAt) {
|
|
239
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-expiry', `This approval expires at ${mint.expiresAt}.`));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What a commit's own words mean for what happens next. The server's `message` is shown
|
|
245
|
+
* as-is; this decides whether the RESTART step is now the operator's next move.
|
|
246
|
+
*/
|
|
247
|
+
export function nextStepAfterCommit(result) {
|
|
248
|
+
const r = result || {};
|
|
249
|
+
// ORDER IS LOAD-BEARING. A store-only disable under a config enable comes back with
|
|
250
|
+
// `enforcementReady: true` — the layer really is still enforcing, because clearing the
|
|
251
|
+
// operator flag did not stop it. Checking `enforcementReady` first would report that
|
|
252
|
+
// outcome as 'done', which is precisely the "I disabled it and it is still on" reading
|
|
253
|
+
// the whole surface exists to make legible. What the operator DID is decided first.
|
|
254
|
+
if (r.storeCleared === true) {
|
|
255
|
+
const sources = r.enableSources || {};
|
|
256
|
+
return sources.config === true ? 'still-enabled-by-config' : 'done';
|
|
257
|
+
}
|
|
258
|
+
if (r.enforcementReady === true) return 'done';
|
|
259
|
+
return 'restart';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Render the state block. `target` is replaced. */
|
|
263
|
+
export function renderEnablePanel(doc, target, status, opts = {}) {
|
|
264
|
+
if (!target) return;
|
|
265
|
+
target.replaceChildren();
|
|
266
|
+
if (!status) {
|
|
267
|
+
target.appendChild(el(doc, 'div', 'spend-arm-note',
|
|
268
|
+
'Could not read whether the spending controls are on. Nothing has changed.'));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const head = enableHeadline(status);
|
|
272
|
+
const box = el(doc, 'div', `mle-state mle-state-${head.state}`);
|
|
273
|
+
box.appendChild(el(doc, 'div', 'mle-headline', head.headline));
|
|
274
|
+
box.appendChild(el(doc, 'div', 'mle-detail', head.detail));
|
|
275
|
+
const notice = configSourceNotice(status);
|
|
276
|
+
if (notice) box.appendChild(el(doc, 'div', 'mle-config-notice', notice));
|
|
277
|
+
if (status.anyKeyFrozen === true) {
|
|
278
|
+
box.appendChild(el(doc, 'div', 'mle-detail', 'At least one door is frozen — that door cannot spend.'));
|
|
279
|
+
}
|
|
280
|
+
if (status.machineNickname) {
|
|
281
|
+
box.appendChild(el(doc, 'div', 'mle-machine', `These controls belong to ${status.machineNickname}.`));
|
|
282
|
+
}
|
|
283
|
+
target.appendChild(box);
|
|
284
|
+
|
|
285
|
+
const btns = el(doc, 'div', 'mle-actions');
|
|
286
|
+
for (const a of availableActions(status)) {
|
|
287
|
+
const b = el(doc, 'button', 'spend-arm-btn', a.label);
|
|
288
|
+
b.setAttribute('data-mle-action', a.action);
|
|
289
|
+
if (typeof opts.onAction === 'function') b.addEventListener('click', () => opts.onAction(a.action));
|
|
290
|
+
btns.appendChild(b);
|
|
291
|
+
}
|
|
292
|
+
target.appendChild(btns);
|
|
293
|
+
}
|
|
@@ -123,6 +123,31 @@ export function commitRequest(plan, pin) {
|
|
|
123
123
|
return { planId: (plan || {}).planId, nonce: (plan || {}).nonce, pin };
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* WHICH route commits a rendered plan.
|
|
128
|
+
*
|
|
129
|
+
* The server has one commit route per action and each REFUSES a plan whose signed action
|
|
130
|
+
* is not its own — the plan-binding discipline, working exactly as designed. The dashboard
|
|
131
|
+
* posted every plan to `/routing-spend/caps/adjust`, so "Preview go live" rendered its plan
|
|
132
|
+
* correctly, took the operator's PIN, and then failed with a 400 they could do nothing
|
|
133
|
+
* about: arming a door — the whole point of the panel — was unreachable from the screen
|
|
134
|
+
* built to do it. Found by driving the panel against a real server, not by a test; the unit
|
|
135
|
+
* tests were green because none of them knew a second route existed.
|
|
136
|
+
*
|
|
137
|
+
* Derived from the PLAN's action, never from the button that was pressed: the server may
|
|
138
|
+
* substitute a different action than the one requested, and the commit must follow what was
|
|
139
|
+
* actually rendered and approved.
|
|
140
|
+
*/
|
|
141
|
+
export function commitRoute(plan) {
|
|
142
|
+
const action = plan && typeof plan.action === 'string' ? plan.action : '';
|
|
143
|
+
if (action === 'go-live') return '/routing-spend/go-live';
|
|
144
|
+
if (action === 'unfreeze') return '/routing-spend/unfreeze';
|
|
145
|
+
if (action === 'caps-adjust') return '/routing-spend/caps/adjust';
|
|
146
|
+
// An unknown action gets NO route rather than a default one: posting an unrecognised plan
|
|
147
|
+
// to the caps route is how this defect happened, and the server would refuse it anyway.
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
126
151
|
/** Render the server's plan text for approval. The text is the server's, shown verbatim. */
|
|
127
152
|
export function renderPlanPreview(doc, target, plan) {
|
|
128
153
|
if (!target) return;
|