instar 1.3.1170 → 1.3.1171
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 +245 -2
- package/dashboard/spend-arming.js +170 -0
- 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/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- 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.1171.md +56 -0
- package/upgrades/side-effects/spend-arming-ui.md +86 -0
package/dashboard/index.html
CHANGED
|
@@ -3541,8 +3541,8 @@
|
|
|
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
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>.
|
|
3545
|
-
|
|
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.
|
|
3546
3546
|
</div>
|
|
3547
3547
|
|
|
3548
3548
|
<!-- Glance floors F10/F11 (topic 29836, Phase 3): the shared component renders
|
|
@@ -3550,6 +3550,12 @@
|
|
|
3550
3550
|
became plain words at the glance ("pay-per-use"); the per-model math and the
|
|
3551
3551
|
paid-door caps moved one drill down. -->
|
|
3552
3552
|
<div id="spendGlance" class="glance-root"></div>
|
|
3553
|
+
|
|
3554
|
+
<!-- Paid-door arming (Increment B UI). Two PIN-committed steps: set both ceilings,
|
|
3555
|
+
then take the door live. The server renders the exact wording of each step and
|
|
3556
|
+
the commit sends back only the plan id + nonce, so a field the operator never
|
|
3557
|
+
saw rendered cannot land. Freeze needs no PIN — halting money is always cheap. -->
|
|
3558
|
+
<div id="spendArming" class="spend-arm-root"></div>
|
|
3553
3559
|
</div>
|
|
3554
3560
|
|
|
3555
3561
|
<!-- Evidence Tab (WikiClaim Phase 4) — per-entity evidence + reverse "what cites this?" view.
|
|
@@ -3634,6 +3640,28 @@
|
|
|
3634
3640
|
|
|
3635
3641
|
<!-- subscriptions tab (Subscription & Auth Standard P2.2 — live quota + pending logins) -->
|
|
3636
3642
|
<style>
|
|
3643
|
+
/* Paid-door arming controls (Spend tab). Mobile-first: full-width stacked fields
|
|
3644
|
+
and 44px touch targets, since the operator reaches this from their phone. */
|
|
3645
|
+
.spend-arm-root { display:flex; flex-direction:column; gap:12px; margin-top:8px;
|
|
3646
|
+
border-top:1px solid var(--border,#2a2a2a); padding-top:16px; }
|
|
3647
|
+
.spend-arm-h { margin:0; font-size:16px; }
|
|
3648
|
+
.spend-arm-field { display:flex; flex-direction:column; gap:6px; font-size:13px; opacity:.9; }
|
|
3649
|
+
.spend-arm-input { width:100%; box-sizing:border-box; min-height:44px; padding:10px 12px;
|
|
3650
|
+
font-size:16px; /* 16px stops iOS zooming the page on focus */
|
|
3651
|
+
border:1px solid var(--border,#2a2a2a); border-radius:8px; background:transparent; color:inherit; }
|
|
3652
|
+
.spend-arm-hint { font-size:12px; opacity:.6; }
|
|
3653
|
+
.spend-arm-btns { display:flex; flex-wrap:wrap; gap:8px; }
|
|
3654
|
+
.spend-arm-btn { min-height:44px; padding:10px 14px; font-size:14px; border-radius:8px;
|
|
3655
|
+
border:1px solid var(--border,#2a2a2a); background:transparent; color:inherit; cursor:pointer; flex:1 1 auto; }
|
|
3656
|
+
.spend-arm-btn-freeze { border-color:#b45309; color:#f59e0b; }
|
|
3657
|
+
.spend-arm-btn-commit { border-color:#16a34a; color:#22c55e; width:100%; margin-top:10px; }
|
|
3658
|
+
.spend-arm-plan { display:flex; flex-direction:column; gap:8px; }
|
|
3659
|
+
.spend-arm-plan-label { font-size:12px; font-weight:700; letter-spacing:.05em; text-transform:uppercase; opacity:.6; }
|
|
3660
|
+
.spend-arm-plan-text { font-size:14px; line-height:1.5; padding:12px; border-radius:8px;
|
|
3661
|
+
border:1px solid var(--border,#2a2a2a); white-space:pre-wrap; word-break:break-word; }
|
|
3662
|
+
.spend-arm-plan-expiry, .spend-arm-note { font-size:12px; opacity:.7; }
|
|
3663
|
+
@media (max-width:520px) { .spend-arm-btn { flex:1 1 100%; } }
|
|
3664
|
+
|
|
3637
3665
|
/* Provider group heading — only rendered when more than one provider is
|
|
3638
3666
|
enrolled, so a single-provider install looks exactly as it did before. */
|
|
3639
3667
|
.sub-provider-heading { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
|
|
@@ -6010,6 +6038,10 @@
|
|
|
6010
6038
|
|
|
6011
6039
|
const spec = glance.spendGlanceSpec(document, summary, caps);
|
|
6012
6040
|
glance.renderGlance(document, glanceRoot, spec);
|
|
6041
|
+
// The arming controls sit below the read-only glance. Rendered from the SAME caps
|
|
6042
|
+
// payload, so the door list and its live/frozen wording cannot drift from the view
|
|
6043
|
+
// above it.
|
|
6044
|
+
renderSpendArming(caps);
|
|
6013
6045
|
}
|
|
6014
6046
|
|
|
6015
6047
|
async function loadResources() {
|
|
@@ -8635,6 +8667,217 @@
|
|
|
8635
8667
|
return __glanceModule;
|
|
8636
8668
|
}
|
|
8637
8669
|
|
|
8670
|
+
// ── Spend tab — paid-door arming (Increment B UI) ──
|
|
8671
|
+
// Two PIN-committed steps (set both ceilings, then go live), each previewed as the
|
|
8672
|
+
// SERVER's rendered wording before the PIN box appears. The commit posts only the
|
|
8673
|
+
// plan id + nonce + PIN, never the form fields.
|
|
8674
|
+
let __spendArmModule = null;
|
|
8675
|
+
async function loadSpendArmModule() {
|
|
8676
|
+
if (!__spendArmModule) {
|
|
8677
|
+
try { __spendArmModule = await import('/dashboard/spend-arming.js'); }
|
|
8678
|
+
catch (e) { console.error('[spend-arming] module load failed', e); return null; }
|
|
8679
|
+
}
|
|
8680
|
+
return __spendArmModule;
|
|
8681
|
+
}
|
|
8682
|
+
|
|
8683
|
+
// The plan currently awaiting approval. Cleared whenever inputs change, so a PIN can
|
|
8684
|
+
// never be applied to a plan the operator has since edited away from.
|
|
8685
|
+
let __spendPendingPlan = null;
|
|
8686
|
+
|
|
8687
|
+
async function renderSpendArming(caps) {
|
|
8688
|
+
const root = document.getElementById('spendArming');
|
|
8689
|
+
if (!root) return;
|
|
8690
|
+
const M = await loadSpendArmModule();
|
|
8691
|
+
if (!M) { root.textContent = 'Loading the money controls failed — refresh to retry.'; return; }
|
|
8692
|
+
root.replaceChildren();
|
|
8693
|
+
|
|
8694
|
+
const h = document.createElement('h3');
|
|
8695
|
+
h.textContent = 'Paid doors';
|
|
8696
|
+
h.className = 'spend-arm-h';
|
|
8697
|
+
root.appendChild(h);
|
|
8698
|
+
|
|
8699
|
+
const rows = M.armableDoors(caps);
|
|
8700
|
+
if (rows.length === 0) {
|
|
8701
|
+
const n = document.createElement('div');
|
|
8702
|
+
n.className = 'spend-arm-note';
|
|
8703
|
+
n.textContent = 'No pay-per-use doors are configured on this agent.';
|
|
8704
|
+
root.appendChild(n);
|
|
8705
|
+
return;
|
|
8706
|
+
}
|
|
8707
|
+
|
|
8708
|
+
const sel = document.createElement('select');
|
|
8709
|
+
sel.id = 'spendArmKey';
|
|
8710
|
+
sel.className = 'spend-arm-input';
|
|
8711
|
+
for (const r of rows) {
|
|
8712
|
+
const o = document.createElement('option');
|
|
8713
|
+
o.value = r.keyRef;
|
|
8714
|
+
// textContent — keyRef/provider are server data, never markup.
|
|
8715
|
+
o.textContent = `${r.provider} · ${r.door} — ${M.doorStateWords(r)}`;
|
|
8716
|
+
sel.appendChild(o);
|
|
8717
|
+
}
|
|
8718
|
+
const selWrap = document.createElement('label');
|
|
8719
|
+
selWrap.className = 'spend-arm-field';
|
|
8720
|
+
selWrap.appendChild(document.createTextNode('Which door'));
|
|
8721
|
+
selWrap.appendChild(sel);
|
|
8722
|
+
root.appendChild(selWrap);
|
|
8723
|
+
|
|
8724
|
+
const mkNum = (id, label, hint) => {
|
|
8725
|
+
const w = document.createElement('label');
|
|
8726
|
+
w.className = 'spend-arm-field';
|
|
8727
|
+
w.appendChild(document.createTextNode(label));
|
|
8728
|
+
const i = document.createElement('input');
|
|
8729
|
+
i.id = id; i.type = 'number'; i.min = '0'; i.step = '0.01';
|
|
8730
|
+
i.className = 'spend-arm-input'; i.inputMode = 'decimal';
|
|
8731
|
+
i.addEventListener('input', () => { __spendPendingPlan = null; renderSpendPlan(null); });
|
|
8732
|
+
w.appendChild(i);
|
|
8733
|
+
const hn = document.createElement('div');
|
|
8734
|
+
hn.className = 'spend-arm-hint'; hn.textContent = hint;
|
|
8735
|
+
w.appendChild(hn);
|
|
8736
|
+
return w;
|
|
8737
|
+
};
|
|
8738
|
+
root.appendChild(mkNum('spendArmLifetime', 'Lifetime ceiling (USD)',
|
|
8739
|
+
'The total this door may ever spend. It stops permanently here.'));
|
|
8740
|
+
root.appendChild(mkNum('spendArmDaily', 'Daily ceiling (USD)',
|
|
8741
|
+
'The most it may spend in one day. Both ceilings are required.'));
|
|
8742
|
+
sel.addEventListener('change', () => { __spendPendingPlan = null; renderSpendPlan(null); });
|
|
8743
|
+
|
|
8744
|
+
const btns = document.createElement('div');
|
|
8745
|
+
btns.className = 'spend-arm-btns';
|
|
8746
|
+
const mkBtn = (text, cls, fn) => {
|
|
8747
|
+
const b = document.createElement('button');
|
|
8748
|
+
b.textContent = text; b.className = cls; b.onclick = fn; return b;
|
|
8749
|
+
};
|
|
8750
|
+
btns.appendChild(mkBtn('Preview cap change', 'spend-arm-btn', () => previewSpendPlan('caps-adjust')));
|
|
8751
|
+
btns.appendChild(mkBtn('Preview go live', 'spend-arm-btn', () => previewSpendPlan('go-live')));
|
|
8752
|
+
btns.appendChild(mkBtn('Freeze this door', 'spend-arm-btn spend-arm-btn-freeze', freezeSpendDoor));
|
|
8753
|
+
root.appendChild(btns);
|
|
8754
|
+
|
|
8755
|
+
const planBox = document.createElement('div');
|
|
8756
|
+
planBox.id = 'spendArmPlan'; planBox.className = 'spend-arm-plan';
|
|
8757
|
+
root.appendChild(planBox);
|
|
8758
|
+
|
|
8759
|
+
const status = document.createElement('div');
|
|
8760
|
+
status.id = 'spendArmStatus'; status.className = 'spend-arm-note';
|
|
8761
|
+
root.appendChild(status);
|
|
8762
|
+
}
|
|
8763
|
+
|
|
8764
|
+
function selectedSpendRow(caps, M) {
|
|
8765
|
+
const key = (document.getElementById('spendArmKey') || {}).value;
|
|
8766
|
+
return M.armableDoors(caps).find((r) => r.keyRef === key) || null;
|
|
8767
|
+
}
|
|
8768
|
+
|
|
8769
|
+
function renderSpendPlan(plan) {
|
|
8770
|
+
const box = document.getElementById('spendArmPlan');
|
|
8771
|
+
if (!box) return;
|
|
8772
|
+
loadSpendArmModule().then((M) => {
|
|
8773
|
+
if (!M) return;
|
|
8774
|
+
M.renderPlanPreview(document, box, plan);
|
|
8775
|
+
if (!plan) return;
|
|
8776
|
+
// The PIN box appears ONLY once there is a rendered plan to approve, so the
|
|
8777
|
+
// operator never types a secret against something they have not read.
|
|
8778
|
+
const w = document.createElement('label');
|
|
8779
|
+
w.className = 'spend-arm-field';
|
|
8780
|
+
w.appendChild(document.createTextNode('Dashboard PIN'));
|
|
8781
|
+
const p = document.createElement('input');
|
|
8782
|
+
p.type = 'password'; p.id = 'spendArmPin'; p.className = 'spend-arm-input';
|
|
8783
|
+
p.autocomplete = 'off'; p.inputMode = 'numeric';
|
|
8784
|
+
w.appendChild(p);
|
|
8785
|
+
box.appendChild(w);
|
|
8786
|
+
const b = document.createElement('button');
|
|
8787
|
+
b.className = 'spend-arm-btn spend-arm-btn-commit';
|
|
8788
|
+
b.textContent = 'Approve with PIN';
|
|
8789
|
+
b.onclick = commitSpendPlan;
|
|
8790
|
+
box.appendChild(b);
|
|
8791
|
+
});
|
|
8792
|
+
}
|
|
8793
|
+
|
|
8794
|
+
function setSpendArmStatus(text) {
|
|
8795
|
+
const s = document.getElementById('spendArmStatus');
|
|
8796
|
+
if (s) s.textContent = text;
|
|
8797
|
+
}
|
|
8798
|
+
|
|
8799
|
+
async function previewSpendPlan(action) {
|
|
8800
|
+
const M = await loadSpendArmModule();
|
|
8801
|
+
if (!M) return;
|
|
8802
|
+
const caps = await apiFetch('/routing-spend/caps').catch(() => null);
|
|
8803
|
+
const row = selectedSpendRow(caps, M);
|
|
8804
|
+
if (!row) { setSpendArmStatus('Pick a door first.'); return; }
|
|
8805
|
+
|
|
8806
|
+
let fields;
|
|
8807
|
+
if (action === 'caps-adjust') {
|
|
8808
|
+
const num = (id) => {
|
|
8809
|
+
const raw = (document.getElementById(id) || {}).value;
|
|
8810
|
+
return raw === '' || raw === undefined ? '' : Number(raw);
|
|
8811
|
+
};
|
|
8812
|
+
const lifetimeCapUsd = num('spendArmLifetime');
|
|
8813
|
+
const dailyCapUsd = num('spendArmDaily');
|
|
8814
|
+
const v = M.validateCaps({ lifetimeCapUsd, dailyCapUsd });
|
|
8815
|
+
if (!v.ok) { setSpendArmStatus(v.error); return; }
|
|
8816
|
+
fields = { keyRef: row.keyRef, provider: row.provider, lifetimeCapUsd, dailyCapUsd };
|
|
8817
|
+
} else {
|
|
8818
|
+
fields = { door: row.door, keyRef: row.keyRef, enabled: true };
|
|
8819
|
+
}
|
|
8820
|
+
|
|
8821
|
+
try {
|
|
8822
|
+
const plan = await apiFetch('/routing-spend/plan', {
|
|
8823
|
+
method: 'POST',
|
|
8824
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8825
|
+
body: JSON.stringify(M.planRequest(action, fields)),
|
|
8826
|
+
});
|
|
8827
|
+
__spendPendingPlan = plan;
|
|
8828
|
+
setSpendArmStatus('');
|
|
8829
|
+
renderSpendPlan(plan);
|
|
8830
|
+
} catch (err) {
|
|
8831
|
+
__spendPendingPlan = null;
|
|
8832
|
+
renderSpendPlan(null);
|
|
8833
|
+
setSpendArmStatus(M.moneyLayerNote(err && err.message));
|
|
8834
|
+
}
|
|
8835
|
+
}
|
|
8836
|
+
|
|
8837
|
+
async function commitSpendPlan() {
|
|
8838
|
+
const M = await loadSpendArmModule();
|
|
8839
|
+
if (!M || !__spendPendingPlan) { setSpendArmStatus('Preview a change first.'); return; }
|
|
8840
|
+
const pinEl = document.getElementById('spendArmPin');
|
|
8841
|
+
const pin = pinEl ? pinEl.value : '';
|
|
8842
|
+
if (!pin) { setSpendArmStatus('Enter your dashboard PIN to approve.'); return; }
|
|
8843
|
+
try {
|
|
8844
|
+
await apiFetch('/routing-spend/caps/adjust', {
|
|
8845
|
+
method: 'POST',
|
|
8846
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8847
|
+
body: JSON.stringify(M.commitRequest(__spendPendingPlan, pin)),
|
|
8848
|
+
});
|
|
8849
|
+
if (pinEl) pinEl.value = '';
|
|
8850
|
+
__spendPendingPlan = null;
|
|
8851
|
+
renderSpendPlan(null);
|
|
8852
|
+
setSpendArmStatus('Applied.');
|
|
8853
|
+
loadRoutingSpend();
|
|
8854
|
+
} catch (err) {
|
|
8855
|
+
if (pinEl) pinEl.value = '';
|
|
8856
|
+
setSpendArmStatus(M.moneyLayerNote(err && err.message));
|
|
8857
|
+
}
|
|
8858
|
+
}
|
|
8859
|
+
|
|
8860
|
+
async function freezeSpendDoor() {
|
|
8861
|
+
const M = await loadSpendArmModule();
|
|
8862
|
+
if (!M) return;
|
|
8863
|
+
const caps = await apiFetch('/routing-spend/caps').catch(() => null);
|
|
8864
|
+
const row = selectedSpendRow(caps, M);
|
|
8865
|
+
if (!row) { setSpendArmStatus('Pick a door first.'); return; }
|
|
8866
|
+
try {
|
|
8867
|
+
// No PIN: halting money is always cheap and always available. UNfreezing is the
|
|
8868
|
+
// PIN-gated direction.
|
|
8869
|
+
await apiFetch('/routing-spend/freeze', {
|
|
8870
|
+
method: 'POST',
|
|
8871
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8872
|
+
body: JSON.stringify({ keyRef: row.keyRef }),
|
|
8873
|
+
});
|
|
8874
|
+
setSpendArmStatus('Frozen. Spending on that door is halted.');
|
|
8875
|
+
loadRoutingSpend();
|
|
8876
|
+
} catch (err) {
|
|
8877
|
+
setSpendArmStatus(M.moneyLayerNote(err && err.message));
|
|
8878
|
+
}
|
|
8879
|
+
}
|
|
8880
|
+
|
|
8638
8881
|
async function loadCommitments() {
|
|
8639
8882
|
const glanceRoot = document.getElementById('commitmentsGlance');
|
|
8640
8883
|
const countBadge = document.getElementById('tabCommitmentCount');
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend tab — the paid-door arming controls (Routing Control Room, Increment B UI).
|
|
3
|
+
*
|
|
4
|
+
* The money layer's routes existed but had no screen, while the Spend tab told the
|
|
5
|
+
* operator that caps and go-live were "a later increment". So an operator told the
|
|
6
|
+
* arming was "one tap in the Spend tab, enter your PIN" went looking for a PIN box
|
|
7
|
+
* that had never been built. This is that screen.
|
|
8
|
+
*
|
|
9
|
+
* Three things this module refuses to do, because each one caused or repeated that
|
|
10
|
+
* failure:
|
|
11
|
+
*
|
|
12
|
+
* 1. It never claims a control is available when the server says the money layer is
|
|
13
|
+
* off. A 503 renders the REASON — that enabling it is the operator's own switch,
|
|
14
|
+
* reserved to them by design — not a generic "something went wrong".
|
|
15
|
+
* 2. It never sends the PIN anywhere except the commit call. The plan/preview call
|
|
16
|
+
* is deliberately PIN-free, so the operator sees exactly what they are approving
|
|
17
|
+
* BEFORE any secret leaves the page.
|
|
18
|
+
* 3. It never derives what will be committed from the form. The server renders the
|
|
19
|
+
* plan text; the commit sends back only the plan id and nonce. A field the
|
|
20
|
+
* operator never saw rendered cannot land.
|
|
21
|
+
*
|
|
22
|
+
* Every dynamic value is written with textContent (same contract as subscriptions.js).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Element helper — textContent ONLY, never innerHTML. */
|
|
26
|
+
function el(doc, tag, cls, text) {
|
|
27
|
+
const n = doc.createElement(tag);
|
|
28
|
+
if (cls) n.className = cls;
|
|
29
|
+
if (text !== undefined && text !== null) n.textContent = String(text);
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What to tell the operator when a money-layer call fails.
|
|
35
|
+
*
|
|
36
|
+
* The 503 case is the one that matters. "Money controls are unavailable" would repeat
|
|
37
|
+
* the original confusion — the operator would go looking for what they did wrong. The
|
|
38
|
+
* money layer being off is not a fault and not something they can fix from this screen;
|
|
39
|
+
* it is a switch deliberately reserved to them, and saying so is the whole point.
|
|
40
|
+
*/
|
|
41
|
+
export function moneyLayerNote(message) {
|
|
42
|
+
const m = message === undefined || message === null ? '' : String(message);
|
|
43
|
+
if (/503|not enabled|money layer/i.test(m)) {
|
|
44
|
+
return 'Money controls are switched off on this agent. Turning them on is your decision — ' +
|
|
45
|
+
'it is deliberately not something an agent or a developer can flip. Once it is on, these ' +
|
|
46
|
+
'controls work; until then nothing here can arm a door.';
|
|
47
|
+
}
|
|
48
|
+
if (/caps store unreadable/i.test(m)) {
|
|
49
|
+
return 'The caps record could not be read, so money controls are refusing to act rather ' +
|
|
50
|
+
'than guess. Nothing has changed.';
|
|
51
|
+
}
|
|
52
|
+
if (/pin/i.test(m)) {
|
|
53
|
+
return 'That PIN was not accepted. Nothing has changed.';
|
|
54
|
+
}
|
|
55
|
+
return 'Could not reach the money controls just now. Nothing has changed — try again.';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Validate the two ceilings before asking the server for a plan.
|
|
60
|
+
*
|
|
61
|
+
* BOTH are required: the server takes a lifetime ceiling and a daily ceiling, and there
|
|
62
|
+
* is no monthly figure to fall back on. A monthly intent has to be expressed as both, and
|
|
63
|
+
* leaving one blank silently produces a very different product — a daily rate with no
|
|
64
|
+
* lifetime bound is a tap left running, not a budget.
|
|
65
|
+
*/
|
|
66
|
+
export function validateCaps(input) {
|
|
67
|
+
const { lifetimeCapUsd, dailyCapUsd } = input || {};
|
|
68
|
+
for (const [name, label] of [['lifetimeCapUsd', 'lifetime'], ['dailyCapUsd', 'daily']]) {
|
|
69
|
+
const v = (input || {})[name];
|
|
70
|
+
if (v === '' || v === null || v === undefined) {
|
|
71
|
+
return { ok: false, error: `Enter a ${label} ceiling — the server requires both.` };
|
|
72
|
+
}
|
|
73
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
74
|
+
return { ok: false, error: `The ${label} ceiling must be a number.` };
|
|
75
|
+
}
|
|
76
|
+
if (v <= 0) {
|
|
77
|
+
return { ok: false, error: `The ${label} ceiling must be greater than zero.` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (dailyCapUsd > lifetimeCapUsd) {
|
|
81
|
+
return {
|
|
82
|
+
ok: false,
|
|
83
|
+
error: 'The daily ceiling is above the lifetime ceiling, so the daily one could never ' +
|
|
84
|
+
'bind. Lower the daily figure or raise the lifetime one.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The request body for a plan preview. Deliberately a pure function so a test can prove
|
|
92
|
+
* the PIN is NOT in it — a preview that carried the secret would defeat the point of
|
|
93
|
+
* showing the operator what they are approving first.
|
|
94
|
+
*/
|
|
95
|
+
export function planRequest(action, fields) {
|
|
96
|
+
const f = fields || {};
|
|
97
|
+
if (action === 'caps-adjust') {
|
|
98
|
+
return {
|
|
99
|
+
action: 'caps-adjust',
|
|
100
|
+
keyRef: f.keyRef,
|
|
101
|
+
provider: f.provider,
|
|
102
|
+
lifetimeCapUsd: f.lifetimeCapUsd,
|
|
103
|
+
dailyCapUsd: f.dailyCapUsd,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (action === 'go-live') {
|
|
107
|
+
return { action: 'go-live', door: f.door, keyRef: f.keyRef, enabled: f.enabled === true };
|
|
108
|
+
}
|
|
109
|
+
if (action === 'unfreeze') {
|
|
110
|
+
return { action: 'unfreeze', keyRef: f.keyRef };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The commit body. Carries ONLY the plan identity and the PIN — never the form fields.
|
|
117
|
+
*
|
|
118
|
+
* This is the load-bearing security shape: the server derives what to apply solely from
|
|
119
|
+
* the plan it rendered, so a value the operator never saw on screen cannot be committed
|
|
120
|
+
* by editing the form after previewing.
|
|
121
|
+
*/
|
|
122
|
+
export function commitRequest(plan, pin) {
|
|
123
|
+
return { planId: (plan || {}).planId, nonce: (plan || {}).nonce, pin };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Render the server's plan text for approval. The text is the server's, shown verbatim. */
|
|
127
|
+
export function renderPlanPreview(doc, target, plan) {
|
|
128
|
+
if (!target) return;
|
|
129
|
+
target.replaceChildren();
|
|
130
|
+
if (!plan || !plan.renderedText) {
|
|
131
|
+
target.appendChild(el(doc, 'div', 'spend-arm-note', 'No plan to approve yet.'));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-label', 'Approve exactly this:'));
|
|
135
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-text', plan.renderedText));
|
|
136
|
+
if (plan.expiresAt) {
|
|
137
|
+
target.appendChild(el(doc, 'div', 'spend-arm-plan-expiry', `This approval expires at ${plan.expiresAt}.`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The per-door rows the arming panel offers.
|
|
143
|
+
*
|
|
144
|
+
* Only METERED (paid) doors can be armed, so subscription doors are excluded rather than
|
|
145
|
+
* rendered as inert rows — an unarmable row invites the same "where do I tap?" confusion
|
|
146
|
+
* this screen exists to remove.
|
|
147
|
+
*/
|
|
148
|
+
export function armableDoors(caps) {
|
|
149
|
+
const keys = (caps && Array.isArray(caps.keys)) ? caps.keys : [];
|
|
150
|
+
return keys
|
|
151
|
+
.filter((k) => k && typeof k.keyRef === 'string' && k.keyRef.length > 0)
|
|
152
|
+
.map((k) => ({
|
|
153
|
+
keyRef: k.keyRef,
|
|
154
|
+
provider: k.provider,
|
|
155
|
+
door: k.door,
|
|
156
|
+
lifetimeCapUsd: k.lifetimeCapUsd,
|
|
157
|
+
dailyCapUsd: k.dailyCapUsd,
|
|
158
|
+
frozen: k.frozen === true,
|
|
159
|
+
goLiveState: k.goLiveState,
|
|
160
|
+
live: k.goLiveState === 'live',
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Plain-language state for a door, so the operator can tell armed from not-armed. */
|
|
165
|
+
export function doorStateWords(row) {
|
|
166
|
+
if (!row) return '';
|
|
167
|
+
if (row.frozen) return 'Frozen — spending is halted';
|
|
168
|
+
if (row.live) return 'Live — this door can spend';
|
|
169
|
+
return 'Not live — this door cannot spend';
|
|
170
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedFrom": "source-tree",
|
|
4
4
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
5
|
-
"packageVersion": "1.3.
|
|
5
|
+
"packageVersion": "1.3.1171",
|
|
6
6
|
"guards": [
|
|
7
7
|
{
|
|
8
8
|
"ref": "docs/audits/phase-b/f10-triage.md",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sha256": "
|
|
2
|
+
"sha256": "878fad6f61a7948606c4c2d116b92656e0449bab6e005987433fc6bcef6309e6",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1171"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-08-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-08-16T04:17:05.922Z",
|
|
5
|
+
"instarVersion": "1.3.1171",
|
|
6
6
|
"entryCount": 202,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedFrom": "source-tree",
|
|
4
4
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
5
|
-
"packageVersion": "1.3.
|
|
5
|
+
"packageVersion": "1.3.1171",
|
|
6
6
|
"guards": [
|
|
7
7
|
{
|
|
8
8
|
"ref": "docs/audits/phase-b/f10-triage.md",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sha256": "
|
|
2
|
+
"sha256": "878fad6f61a7948606c4c2d116b92656e0449bab6e005987433fc6bcef6309e6",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1171"
|
|
5
5
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The Spend tab gains the screen for arming a paid door — setting its spending limits and taking
|
|
9
|
+
it live. The machinery for this existed and worked; the screen did not, and the tab's own small
|
|
10
|
+
print said caps and go-live were "a later increment". So an operator told arming was "one tap,
|
|
11
|
+
enter your PIN" went looking for a PIN box that had never been drawn. That copy is corrected too.
|
|
12
|
+
|
|
13
|
+
## What to Tell Your User
|
|
14
|
+
|
|
15
|
+
- "You can now set a paid door's limits and turn it on from the Spend tab, on your phone."
|
|
16
|
+
- "It asks for your PIN twice on purpose — once to set the limits, once to start spending."
|
|
17
|
+
- "The freeze button needs no PIN. Stopping money should always be immediate."
|
|
18
|
+
|
|
19
|
+
## Summary of New Capabilities
|
|
20
|
+
|
|
21
|
+
| Capability | How to Use |
|
|
22
|
+
|-----------|-----------|
|
|
23
|
+
| Set a paid door's ceilings | Spend tab → Paid doors → enter both ceilings → Preview → PIN |
|
|
24
|
+
| Take a paid door live | Spend tab → Paid doors → Preview go live → PIN |
|
|
25
|
+
| Freeze a door immediately | Spend tab → Paid doors → Freeze (no PIN) |
|
|
26
|
+
|
|
27
|
+
## Evidence
|
|
28
|
+
|
|
29
|
+
31 unit tests against the shipped module in a real DOM, plus wiring tests asserting that every
|
|
30
|
+
dashboard module the page imports exists on disk — the guard against the "built but unreachable"
|
|
31
|
+
shape this feature is fixing.
|
|
32
|
+
|
|
33
|
+
Shown capable of failing, independently: leaking the PIN into the preview request fails exactly
|
|
34
|
+
the security test; replacing the honest switched-off message with a generic one fails exactly the
|
|
35
|
+
two tests pinning it. A control asserts an ordinary network failure does not claim the money layer
|
|
36
|
+
is off.
|
|
37
|
+
|
|
38
|
+
Not yet proven live: the money layer is off on this agent, so the end-to-end arming path cannot be
|
|
39
|
+
exercised until an operator enables it. That is stated rather than implied away.
|
|
40
|
+
|
|
41
|
+
## Compatibility Notes
|
|
42
|
+
|
|
43
|
+
**The controls cannot arm anything until the money layer is switched on, and that switch is
|
|
44
|
+
reserved to the operator** — deliberately not something an agent or a developer can flip. Used
|
|
45
|
+
while it is off, the screen says exactly that, rather than showing a generic error that would
|
|
46
|
+
send someone hunting for a mistake they did not make.
|
|
47
|
+
|
|
48
|
+
**Both ceilings are required.** A door has a lifetime ceiling and a daily ceiling, and there is
|
|
49
|
+
no monthly setting, so a monthly intention must be expressed as both. The screen refuses a blank
|
|
50
|
+
rather than choosing one silently: $100 lifetime with $3.30/day is a hard budget that also cannot
|
|
51
|
+
burn in an afternoon, while $3.30/day with a large lifetime is a tap left running.
|
|
52
|
+
|
|
53
|
+
**You approve rendered words, not a button.** The server writes out what it is about to do and
|
|
54
|
+
the screen shows that sentence before the PIN box appears. What commits is that plan, not the
|
|
55
|
+
form — so editing a number after previewing invalidates the approval, and a figure never read
|
|
56
|
+
cannot land.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Side-Effects Review — paid-door arming UI
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
The routing-spend money layer's routes (`/routing-spend/plan`, `/caps/adjust`, `/freeze`) had no
|
|
6
|
+
screen, while the Spend tab's own copy said caps and go-live were "a later increment". This adds
|
|
7
|
+
the two-step arming flow and corrects that copy.
|
|
8
|
+
|
|
9
|
+
## How this arose
|
|
10
|
+
|
|
11
|
+
An operator was told arming was "one tap in the Spend tab, enter your PIN". It was not — the tab
|
|
12
|
+
was read-only and said so. They went looking for a PIN box that had never been drawn. That is the
|
|
13
|
+
same class as the enrolment failures earlier tonight: a capability described as reachable that
|
|
14
|
+
had no reachable surface.
|
|
15
|
+
|
|
16
|
+
## Decision-point inventory
|
|
17
|
+
|
|
18
|
+
None added. The screen holds no authority: it renders a plan the SERVER composed and posts back
|
|
19
|
+
the plan identity plus the operator's PIN. The server verifies the PIN and derives what to apply
|
|
20
|
+
from its own rendered plan. The UI cannot arm, adjust, or unfreeze anything on its own.
|
|
21
|
+
|
|
22
|
+
## 1. Over-block
|
|
23
|
+
|
|
24
|
+
`validateCaps` refuses client-side before asking for a plan: a missing ceiling, a non-number, a
|
|
25
|
+
non-positive value, or a daily ceiling above the lifetime ceiling. The last could arguably be
|
|
26
|
+
allowed (the server may accept it), but a daily ceiling that can never bind is almost certainly a
|
|
27
|
+
typo, and the message says why rather than just refusing.
|
|
28
|
+
|
|
29
|
+
## 2. Under-block
|
|
30
|
+
|
|
31
|
+
The client does no authorization at all — deliberately. Every real refusal (bad PIN, money layer
|
|
32
|
+
off, unreadable caps store, expired plan) is the server's, and the UI only renders the reason.
|
|
33
|
+
|
|
34
|
+
## 3. Level-of-abstraction fit
|
|
35
|
+
|
|
36
|
+
Plan rendering stays server-side; the client never composes the sentence the operator approves.
|
|
37
|
+
That is what makes "a field you never saw cannot land" true, and it would be false if the UI
|
|
38
|
+
built its own summary text.
|
|
39
|
+
|
|
40
|
+
## 4. Signal vs authority
|
|
41
|
+
|
|
42
|
+
The UI is pure presentation plus transport. The PIN gate is unchanged and remains server-side.
|
|
43
|
+
|
|
44
|
+
## 5. Interactions
|
|
45
|
+
|
|
46
|
+
The panel renders from the SAME `/routing-spend/caps` payload the read-only glance above it uses,
|
|
47
|
+
so the door list and its live/frozen wording cannot drift from the figures. Editing any input or
|
|
48
|
+
changing the door clears the pending plan, so a PIN can never be applied to a plan the operator
|
|
49
|
+
has since edited away from.
|
|
50
|
+
|
|
51
|
+
## 6. Multi-machine posture
|
|
52
|
+
|
|
53
|
+
Machine-local BY DESIGN — the dashboard is served per machine and the PIN is a per-machine
|
|
54
|
+
secret. Go-live already carries a `designatedMachineId` server-side; the UI does not touch it.
|
|
55
|
+
|
|
56
|
+
## 7. Failure modes
|
|
57
|
+
|
|
58
|
+
Every money-layer failure routes through `moneyLayerNote`, which distinguishes: money layer off
|
|
59
|
+
(names it as the operator's own switch), caps store unreadable (refusing to guess), rejected PIN,
|
|
60
|
+
and everything else. The default says "nothing has changed", which is true on every path because
|
|
61
|
+
the server fails closed. The module has no I/O and every exported function is total (tested
|
|
62
|
+
against undefined/null/junk).
|
|
63
|
+
|
|
64
|
+
**Security**: the PIN never appears in a preview request (pure-function test asserts it), is a
|
|
65
|
+
`type=password` field, is cleared on both success and failure, and is never placed in a URL. All
|
|
66
|
+
rendering is textContent; a hostile plan text is tested for.
|
|
67
|
+
|
|
68
|
+
## 8. Rollback cost
|
|
69
|
+
|
|
70
|
+
Delete the `spendArming` div and the `renderSpendArming(caps)` call. The module is inert without
|
|
71
|
+
them. No state, no migration, no config.
|
|
72
|
+
|
|
73
|
+
## Evidence
|
|
74
|
+
|
|
75
|
+
31 unit tests against the shipped module in a real DOM, plus wiring tests that assert every
|
|
76
|
+
`/dashboard/*.js` the page imports exists on disk — the guard against the "wired but unreachable"
|
|
77
|
+
shape that caused this whole thread.
|
|
78
|
+
|
|
79
|
+
Shown capable of failing, independently: leaking the PIN into the preview request fails exactly
|
|
80
|
+
the security test; replacing the honest 503 message with a generic one fails exactly the two
|
|
81
|
+
tests that pin it. A CONTROL asserts an ordinary network failure does NOT claim the money layer
|
|
82
|
+
is off — without it, every failure would send the operator to flip a switch that is already on.
|
|
83
|
+
|
|
84
|
+
**Not yet proven live.** The money layer is off on this agent, so the arming path cannot be
|
|
85
|
+
driven end-to-end until the operator enables it. That is stated rather than papered over: this
|
|
86
|
+
review does not claim live-channel proof it does not have.
|