instar 1.3.1169 → 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 +250 -2
- package/dashboard/spend-arming.js +170 -0
- package/dashboard/subscriptions.js +50 -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/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.1170.md +55 -0
- package/upgrades/1.3.1171.md +56 -0
- package/upgrades/side-effects/spend-arming-ui.md +86 -0
- package/upgrades/side-effects/subscriptions-group-by-provider.md +70 -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,33 @@
|
|
|
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
|
+
|
|
3665
|
+
/* Provider group heading — only rendered when more than one provider is
|
|
3666
|
+
enrolled, so a single-provider install looks exactly as it did before. */
|
|
3667
|
+
.sub-provider-heading { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
|
|
3668
|
+
opacity:.6; margin:14px 0 8px; padding-bottom:6px; border-bottom:1px solid var(--border,#2a2a2a); }
|
|
3669
|
+
.sub-provider-heading:first-child { margin-top:0; }
|
|
3637
3670
|
.sub-account { border:1px solid var(--border,#2a2a2a); border-radius:10px; padding:14px 16px; margin-bottom:12px; }
|
|
3638
3671
|
.sub-account-inuse { border-color:#16a34a; box-shadow:0 0 0 1px #16a34a44; }
|
|
3639
3672
|
.sub-account-inuse-badge { font-size:11px; font-weight:600; color:#22c55e; margin-right:auto; }
|
|
@@ -6005,6 +6038,10 @@
|
|
|
6005
6038
|
|
|
6006
6039
|
const spec = glance.spendGlanceSpec(document, summary, caps);
|
|
6007
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);
|
|
6008
6045
|
}
|
|
6009
6046
|
|
|
6010
6047
|
async function loadResources() {
|
|
@@ -8630,6 +8667,217 @@
|
|
|
8630
8667
|
return __glanceModule;
|
|
8631
8668
|
}
|
|
8632
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
|
+
|
|
8633
8881
|
async function loadCommitments() {
|
|
8634
8882
|
const glanceRoot = document.getElementById('commitmentsGlance');
|
|
8635
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
|
+
}
|
|
@@ -195,6 +195,47 @@ export function quotaBar(doc, label, pct, resetIso, now = Date.now()) {
|
|
|
195
195
|
return wrap;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Order accounts so each provider's accounts sit together, interleaving a heading
|
|
200
|
+
* marker before each group. Returns a flat list of accounts with `{__providerHeading}`
|
|
201
|
+
* markers spliced in, so the caller keeps ONE loop and one card-rendering path.
|
|
202
|
+
*
|
|
203
|
+
* Two deliberate choices:
|
|
204
|
+
*
|
|
205
|
+
* - **Groups appear in first-appearance order, and accounts keep their order within a
|
|
206
|
+
* group.** Nothing is sorted. The pool's order carries meaning the dashboard cannot
|
|
207
|
+
* see (enrolment order, which account is the default), so grouping re-associates
|
|
208
|
+
* without re-ranking.
|
|
209
|
+
*
|
|
210
|
+
* - **A single-provider list gets NO heading.** Every instar install with one provider
|
|
211
|
+
* renders exactly as it did before — the heading only appears once there is genuinely
|
|
212
|
+
* something to tell apart, which is the condition that prompted this. That property is
|
|
213
|
+
* pinned by a test, because "we grouped everything" would quietly add a redundant
|
|
214
|
+
* heading to the common case.
|
|
215
|
+
*/
|
|
216
|
+
export function groupAccountsByProvider(accounts) {
|
|
217
|
+
if (!Array.isArray(accounts)) return [];
|
|
218
|
+
const groups = new Map(); // provider key (raw) → accounts, in first-appearance order
|
|
219
|
+
for (const a of accounts) {
|
|
220
|
+
const key = a && typeof a.provider === 'string' ? a.provider : '';
|
|
221
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
222
|
+
groups.get(key).push(a);
|
|
223
|
+
}
|
|
224
|
+
const out = [];
|
|
225
|
+
const multi = groups.size > 1;
|
|
226
|
+
for (const [key, members] of groups) {
|
|
227
|
+
if (multi) {
|
|
228
|
+
// friendlyProvider maps anthropic→Claude / openai→Codex and SANITIZES anything
|
|
229
|
+
// else, so an unrecognised or hostile provider string cannot reach the DOM raw.
|
|
230
|
+
// An absent provider has no honest name, so say so rather than invent one.
|
|
231
|
+
const label = friendlyProvider(key) || 'Other';
|
|
232
|
+
out.push({ __providerHeading: label });
|
|
233
|
+
}
|
|
234
|
+
for (const m of members) out.push(m);
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
198
239
|
/** Per-account rows: nickname, status, provider·framework, 5h + weekly quota bars.
|
|
199
240
|
* `inUseAccountId` (optional) is the account the agent is CURRENTLY running on —
|
|
200
241
|
* that card gets an "In use" marker so "active" (healthy) reads distinct from
|
|
@@ -206,7 +247,15 @@ export function renderAccounts(doc, target, accounts, now = Date.now(), inUseAcc
|
|
|
206
247
|
target.appendChild(el(doc, 'div', 'sub-empty', 'No subscription accounts enrolled yet.'));
|
|
207
248
|
return;
|
|
208
249
|
}
|
|
209
|
-
for (const a of accounts) {
|
|
250
|
+
for (const a of groupAccountsByProvider(accounts)) {
|
|
251
|
+
// A group marker, not an account: render the provider heading and continue.
|
|
252
|
+
// (The operator's complaint was a flat interleaved list — Claude and Codex
|
|
253
|
+
// accounts mixed together with only a small per-card meta line to tell them
|
|
254
|
+
// apart. The heading is what makes the split scannable.)
|
|
255
|
+
if (a && a.__providerHeading) {
|
|
256
|
+
target.appendChild(el(doc, 'div', 'sub-provider-heading', a.__providerHeading));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
210
259
|
const inUse = !!(inUseAccountId && a && a.id === inUseAccountId);
|
|
211
260
|
const card = el(doc, 'div', inUse ? 'sub-account sub-account-inuse' : 'sub-account');
|
|
212
261
|
const head = el(doc, 'div', 'sub-account-head');
|
|
@@ -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,55 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The Subscriptions tab now groups your accounts by provider — Claude accounts together, Codex
|
|
9
|
+
accounts together, each under a heading.
|
|
10
|
+
|
|
11
|
+
Before, the list was one flat run of cards in enrolment order, so a Codex account could sit
|
|
12
|
+
between two Claude ones. Each card names its provider, but in small text partway down, so
|
|
13
|
+
telling them apart meant reading every card. With a single provider that was fine. With two it
|
|
14
|
+
stopped being fine.
|
|
15
|
+
|
|
16
|
+
The quota bars live inside the account cards, so grouping the accounts groups the quota view
|
|
17
|
+
along with them.
|
|
18
|
+
|
|
19
|
+
## What to Tell Your User
|
|
20
|
+
|
|
21
|
+
- "Your accounts list is grouped by provider now, so your Codex account no longer sits in the
|
|
22
|
+
middle of your Claude ones."
|
|
23
|
+
- "If you only have one provider, nothing changes at all."
|
|
24
|
+
|
|
25
|
+
## Summary of New Capabilities
|
|
26
|
+
|
|
27
|
+
None. This is a presentation change to an existing tab — no endpoint, no configuration.
|
|
28
|
+
|
|
29
|
+
## Evidence
|
|
30
|
+
|
|
31
|
+
8 tests in the existing jsdom suite, which exercises the shipped dashboard module against a real
|
|
32
|
+
DOM. Shown capable of failing: restoring the flat list fails exactly the four grouping
|
|
33
|
+
assertions while the four invariant tests keep passing.
|
|
34
|
+
|
|
35
|
+
The controls carry the weight — a single-provider list is asserted unchanged, and order within a
|
|
36
|
+
group is asserted preserved. Without them, "grouping works" would pass equally well against a
|
|
37
|
+
change that sorted everything alphabetically and added a redundant heading to every
|
|
38
|
+
single-provider install.
|
|
39
|
+
|
|
40
|
+
The heading is the one new dynamic string reaching the page, so it rides the tab's existing
|
|
41
|
+
sanitize-and-write-as-text contract; a test plants a hostile provider string and confirms no
|
|
42
|
+
live element and no event-handler attribute survives.
|
|
43
|
+
|
|
44
|
+
## Compatibility Notes
|
|
45
|
+
|
|
46
|
+
**A single-provider install is untouched**: with one provider no heading is drawn, and the list
|
|
47
|
+
renders exactly as before. That is deliberate — a heading reading "Claude" above a list of only
|
|
48
|
+
Claude accounts is noise — and it is pinned by a test.
|
|
49
|
+
|
|
50
|
+
Accounts keep their existing order within each group. Nothing is sorted alphabetically, because
|
|
51
|
+
the order accounts arrive in carries meaning the dashboard cannot see (which was enrolled first,
|
|
52
|
+
which is the default). The change re-associates without re-ranking.
|
|
53
|
+
|
|
54
|
+
An account with no provider is still shown, under a heading reading "Other", rather than being
|
|
55
|
+
dropped or filed under a provider it does not belong to.
|
|
@@ -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.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Side-Effects Review — group the accounts list by provider
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
`renderAccounts` drew accounts in a flat pool-ordered run, interleaving providers. It now draws
|
|
6
|
+
each provider's accounts together under a heading, via a new pure `groupAccountsByProvider`.
|
|
7
|
+
|
|
8
|
+
## Decision-point inventory
|
|
9
|
+
|
|
10
|
+
None. This is presentation ordering. It gates nothing, grants nothing, and reads no state
|
|
11
|
+
beyond the accounts array it is already handed.
|
|
12
|
+
|
|
13
|
+
## 1. Over-block / 2. Under-block
|
|
14
|
+
|
|
15
|
+
Not applicable — no admission decision. The nearest analogue is dropping an account from the
|
|
16
|
+
list, which a test forbids explicitly (every account renders exactly once; count asserted).
|
|
17
|
+
|
|
18
|
+
## 3. Level-of-abstraction fit
|
|
19
|
+
|
|
20
|
+
Grouping belongs in the renderer, not the API. The pool's order is meaningful and the server
|
|
21
|
+
should keep returning it as-is; how it is PRESENTED is a dashboard concern. Sorting server-side
|
|
22
|
+
would have imposed one view on every consumer.
|
|
23
|
+
|
|
24
|
+
## 4. Signal vs authority
|
|
25
|
+
|
|
26
|
+
Not applicable — no decision logic.
|
|
27
|
+
|
|
28
|
+
## 5. Interactions
|
|
29
|
+
|
|
30
|
+
The in-use marker, quota bars, status, email and the no-quota fallback all render through the
|
|
31
|
+
unchanged card path; grouping only reorders and interleaves headings. The heading marker is a
|
|
32
|
+
sentinel object (`__providerHeading`) consumed at the top of the same loop, so there is one
|
|
33
|
+
card-rendering path, not two. A test asserts the in-use marker survives grouping.
|
|
34
|
+
|
|
35
|
+
## 6. Multi-machine posture
|
|
36
|
+
|
|
37
|
+
Machine-local BY DESIGN — this is dashboard rendering of data the server already merged.
|
|
38
|
+
Nothing replicates. The pool view's own machine tagging is untouched.
|
|
39
|
+
|
|
40
|
+
## 7. Failure modes
|
|
41
|
+
|
|
42
|
+
`groupAccountsByProvider` handles a non-array (returns []), a missing provider (grouped under
|
|
43
|
+
`Other`), and a non-string provider (coerced to the empty key). It has no I/O and cannot throw
|
|
44
|
+
for any accounts array the existing renderer would have accepted.
|
|
45
|
+
|
|
46
|
+
**Security**: the heading is the only new dynamic string reaching the DOM. It goes through
|
|
47
|
+
`friendlyProvider`, which maps known providers and sanitizes everything else, and is written
|
|
48
|
+
via the module's `el()` textContent helper. A test plants `<img src=x onerror=...>` as a
|
|
49
|
+
provider and asserts no live element and no `onerror` attribute survives.
|
|
50
|
+
|
|
51
|
+
## 8. Rollback cost
|
|
52
|
+
|
|
53
|
+
Change one call site back to `for (const a of accounts)`. No state, no migration, no config.
|
|
54
|
+
|
|
55
|
+
## Evidence
|
|
56
|
+
|
|
57
|
+
8 new tests in the existing jsdom suite, which drives the SHIPPED `dashboard/subscriptions.js`
|
|
58
|
+
against a real DOM. Shown capable of failing: reverting the call site fails exactly the 4
|
|
59
|
+
grouping assertions while the 4 invariant tests keep passing — the correct signature, since
|
|
60
|
+
those describe behaviour that must hold either way.
|
|
61
|
+
|
|
62
|
+
Controls carry the weight here: a single-provider list is asserted UNCHANGED (no heading), and
|
|
63
|
+
account order within a group is asserted preserved. Without those, "grouping works" would pass
|
|
64
|
+
equally well against a change that sorted everything alphabetically and added a redundant
|
|
65
|
+
heading to every single-provider install.
|
|
66
|
+
|
|
67
|
+
One test was WRONG on first run and the code was right: it asserted `innerHTML` lacks the
|
|
68
|
+
substring "onerror", but correctly-escaped text still reads `<img src=x onerror=...>`.
|
|
69
|
+
The assertion now tests the real property — no live element, no such attribute, heading has no
|
|
70
|
+
element children — rather than the absence of characters.
|