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.
@@ -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>No paid door is live yet</b>, so metered spend is $0;
3544
- subscription/CLI doors are <b>$0 (not per-token billed)</b>. The spend figures above are a report;
3545
- the controls below set a door's ceilings and take it live, and each of those takes your PIN.
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 === 'caps-adjust') {
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('/routing-spend/caps/adjust', {
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;