instar 1.3.402 → 1.3.403
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 +74 -0
- package/dashboard/subscriptions.js +264 -0
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.403.md +50 -0
package/dashboard/index.html
CHANGED
|
@@ -2692,6 +2692,7 @@
|
|
|
2692
2692
|
<button class="tab" data-tab="threadline" onclick="switchTab('threadline')">Threadline</button>
|
|
2693
2693
|
<button class="tab" data-tab="evidence" onclick="switchTab('evidence')">Evidence</button>
|
|
2694
2694
|
<button class="tab" data-tab="process-health" onclick="switchTab('process-health')">Process Health</button>
|
|
2695
|
+
<button class="tab" data-tab="subscriptions" onclick="switchTab('subscriptions')">Subscriptions</button>
|
|
2695
2696
|
<button class="tab" data-tab="preferences-learning" onclick="switchTab('preferences-learning')">Preferences</button>
|
|
2696
2697
|
<button class="tab" data-tab="machines" onclick="switchTab('machines')">Machines</button>
|
|
2697
2698
|
<button class="tab" data-tab="mandates" onclick="switchTab('mandates')">Mandates</button>
|
|
@@ -3243,6 +3244,44 @@
|
|
|
3243
3244
|
</details>
|
|
3244
3245
|
</div>
|
|
3245
3246
|
|
|
3247
|
+
<!-- subscriptions tab (Subscription & Auth Standard P2.2 — live quota + pending logins) -->
|
|
3248
|
+
<style>
|
|
3249
|
+
.sub-account { border:1px solid var(--border,#2a2a2a); border-radius:10px; padding:14px 16px; margin-bottom:12px; }
|
|
3250
|
+
.sub-account-head { display:flex; justify-content:space-between; align-items:baseline; gap:12px; }
|
|
3251
|
+
.sub-account-nick { font-weight:600; font-size:15px; }
|
|
3252
|
+
.sub-account-status { font-size:12px; opacity:.75; }
|
|
3253
|
+
.sub-account-meta { font-size:12px; opacity:.6; margin:2px 0 10px; }
|
|
3254
|
+
.sub-account-noquota { font-size:12px; opacity:.6; }
|
|
3255
|
+
.sub-quota { margin:8px 0; }
|
|
3256
|
+
.sub-quota-head { display:flex; justify-content:space-between; font-size:12px; margin-bottom:4px; }
|
|
3257
|
+
.sub-quota-label { opacity:.8; }
|
|
3258
|
+
.sub-quota-pct { opacity:.65; }
|
|
3259
|
+
.sub-quota-track { height:8px; border-radius:4px; background:var(--border,#2a2a2a); overflow:hidden; }
|
|
3260
|
+
.sub-quota-fill { height:100%; background:linear-gradient(90deg,#4caf80,#3a9); }
|
|
3261
|
+
.sub-pending { border:1px solid var(--border,#2a2a2a); border-radius:10px; padding:12px 16px; margin-bottom:10px; }
|
|
3262
|
+
.sub-pending-head { display:flex; justify-content:space-between; align-items:baseline; gap:12px; }
|
|
3263
|
+
.sub-pending-label { font-weight:600; }
|
|
3264
|
+
.sub-pending-ttl { font-size:12px; opacity:.7; }
|
|
3265
|
+
.sub-pending-code { font-family:monospace; font-size:14px; margin-top:6px; }
|
|
3266
|
+
.sub-pending-url { font-size:12px; opacity:.7; word-break:break-all; margin-top:4px; }
|
|
3267
|
+
.sub-pending-reissue { font-size:11px; opacity:.55; margin-top:4px; }
|
|
3268
|
+
.sub-empty, .sub-disabled { font-size:13px; opacity:.7; padding:8px 0; }
|
|
3269
|
+
</style>
|
|
3270
|
+
<div id="subscriptionsPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
|
|
3271
|
+
<h2 class="ph-title">Subscriptions</h2>
|
|
3272
|
+
<p class="ph-intro">Your subscription accounts and how much of each is left before it resets — plus any logins waiting for you to approve on your phone.</p>
|
|
3273
|
+
<section class="ph-section">
|
|
3274
|
+
<h3 class="ph-h">Accounts</h3>
|
|
3275
|
+
<p class="ph-h-sub">Each account’s live usage (5-hour and weekly) and when it resets.</p>
|
|
3276
|
+
<div id="subAccounts"></div>
|
|
3277
|
+
</section>
|
|
3278
|
+
<section class="ph-section">
|
|
3279
|
+
<h3 class="ph-h">Pending logins</h3>
|
|
3280
|
+
<p class="ph-h-sub">Logins waiting for approval — open the link on your phone and confirm. An expired code is re-issued automatically.</p>
|
|
3281
|
+
<div id="subPending"></div>
|
|
3282
|
+
</section>
|
|
3283
|
+
</div>
|
|
3284
|
+
|
|
3246
3285
|
<!-- mandates tab (coordination-mandate spec, decision 2A — the operator's PIN-gated surface) -->
|
|
3247
3286
|
<div id="mandatesPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
|
|
3248
3287
|
<div style="display:flex;justify-content:space-between;align-items:baseline">
|
|
@@ -4650,6 +4689,13 @@
|
|
|
4650
4689
|
onActivate: () => { if (typeof startProcessHealth === 'function') startProcessHealth(); },
|
|
4651
4690
|
onDeactivate: () => { if (typeof stopProcessHealth === 'function') stopProcessHealth(); },
|
|
4652
4691
|
},
|
|
4692
|
+
{
|
|
4693
|
+
id: 'subscriptions',
|
|
4694
|
+
panels: ['subscriptionsPanel'],
|
|
4695
|
+
display: ['flex'],
|
|
4696
|
+
onActivate: () => { if (typeof startSubscriptions === 'function') startSubscriptions(); },
|
|
4697
|
+
onDeactivate: () => { if (typeof stopSubscriptions === 'function') stopSubscriptions(); },
|
|
4698
|
+
},
|
|
4653
4699
|
{
|
|
4654
4700
|
id: 'preferences-learning',
|
|
4655
4701
|
panels: ['preferencesLearningPanel'],
|
|
@@ -8602,6 +8648,34 @@
|
|
|
8602
8648
|
if (__phController) __phController.stop();
|
|
8603
8649
|
}
|
|
8604
8650
|
|
|
8651
|
+
// ── Subscriptions tab (Subscription & Auth Standard P2.2) ──
|
|
8652
|
+
// Same lazy dynamic-import pattern as Process Health: the controller lives in
|
|
8653
|
+
// this (classic) scope so it can read `token`; the module ships the pure
|
|
8654
|
+
// renderers + the polling controller.
|
|
8655
|
+
let __subController = null;
|
|
8656
|
+
let __subModule = null;
|
|
8657
|
+
async function startSubscriptions() {
|
|
8658
|
+
if (!__subModule) {
|
|
8659
|
+
try { __subModule = await import('/dashboard/subscriptions.js'); }
|
|
8660
|
+
catch (e) { console.error('[subscriptions] module load failed', e); return; }
|
|
8661
|
+
}
|
|
8662
|
+
if (!__subController) {
|
|
8663
|
+
const els = {
|
|
8664
|
+
accounts: document.getElementById('subAccounts'),
|
|
8665
|
+
pending: document.getElementById('subPending'),
|
|
8666
|
+
};
|
|
8667
|
+
const fetchImpl = (url, opts = {}) => fetch(url, {
|
|
8668
|
+
headers: { 'Authorization': `Bearer ${token}`, ...(opts.headers || {}) },
|
|
8669
|
+
signal: opts.signal,
|
|
8670
|
+
});
|
|
8671
|
+
__subController = __subModule.createController({ doc: document, els, fetchImpl });
|
|
8672
|
+
}
|
|
8673
|
+
__subController.start();
|
|
8674
|
+
}
|
|
8675
|
+
function stopSubscriptions() {
|
|
8676
|
+
if (__subController) __subController.stop();
|
|
8677
|
+
}
|
|
8678
|
+
|
|
8605
8679
|
// ── Mandates tab (coordination-mandate spec, decision 2A) ──
|
|
8606
8680
|
// Same lazy dynamic-import pattern as Process Health. The PIN typed in this
|
|
8607
8681
|
// tab is sent once per action and never stored anywhere.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Subscriptions tab — a read surface for the multi-account Subscription & Auth
|
|
2
|
+
// pool: per-account live quota bars (5h / weekly + reset countdown), status, and
|
|
3
|
+
// the Pending Logins panel (device codes / verification URLs awaiting approval,
|
|
4
|
+
// with TTL). Spec: docs/specs/_drafts/subscription-auth-standard-master-spec.md.
|
|
5
|
+
//
|
|
6
|
+
// Browser-native ESM (no build step; served at /dashboard/subscriptions.js and
|
|
7
|
+
// loaded by index.html via <script type="module">). The pure functions are
|
|
8
|
+
// exported so the 3-tier jsdom tests exercise the SHIPPED code, not a copy; the
|
|
9
|
+
// controller is attached to window.Subscriptions so index.html drives start/stop
|
|
10
|
+
// on tab activation.
|
|
11
|
+
//
|
|
12
|
+
// Load-bearing safety contract (mirrors the Process Health tab §4.6): every
|
|
13
|
+
// dynamic value flows through sanitizeForDisplay before the DOM; all DOM writes
|
|
14
|
+
// are textContent only (never innerHTML); the only dynamic ATTRIBUTE written is a
|
|
15
|
+
// quota-bar width, set from a clamped NUMBER (0–100) — never a string from data.
|
|
16
|
+
// No verification URL is ever rendered as a live href (defense-in-depth): it is
|
|
17
|
+
// shown as sanitized TEXT for the operator to copy.
|
|
18
|
+
|
|
19
|
+
const CAPS = { label: 64, code: 48, url: 320, summary: 240 };
|
|
20
|
+
|
|
21
|
+
// Structural presentation-glyph class (NFKC-fold THEN strip), identical to the
|
|
22
|
+
// Process Health tab: \p{So} + arrows + geometric + box-drawing + dingbats +
|
|
23
|
+
// variation-selectors + bullet/middot — so a confusable can't impersonate chrome.
|
|
24
|
+
const CHROME_GLYPH_RE = new RegExp(
|
|
25
|
+
'[\\p{So}\\u2190-\\u21FF\\u25A0-\\u25FF\\u2500-\\u257F\\u2700-\\u27BF\\uFE00-\\uFE0F\\u2022\\u00B7\\u2027\\u2043]',
|
|
26
|
+
'gu',
|
|
27
|
+
);
|
|
28
|
+
const CONTROL_RE = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', 'g');
|
|
29
|
+
const BIDI_RE = new RegExp('[\\u202A-\\u202E\\u2066-\\u2069]', 'g');
|
|
30
|
+
|
|
31
|
+
/** Sanitize a dynamic value before it touches the DOM (see contract above). */
|
|
32
|
+
export function sanitizeForDisplay(value, fieldKind = 'summary') {
|
|
33
|
+
let s = value == null ? '' : String(value);
|
|
34
|
+
s = s.normalize('NFKC');
|
|
35
|
+
s = s.replace(CONTROL_RE, '');
|
|
36
|
+
s = s.replace(BIDI_RE, '');
|
|
37
|
+
s = s.replace(/\n{2,}/g, '\n').replace(/[ \t]{5,}/g, ' ');
|
|
38
|
+
s = s.replace(CHROME_GLYPH_RE, '');
|
|
39
|
+
s = capGraphemes(s, CAPS[fieldKind] ?? CAPS.summary);
|
|
40
|
+
return s;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function capGraphemes(s, max) {
|
|
44
|
+
if (s.length <= max) return s;
|
|
45
|
+
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
|
46
|
+
const arr = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s), (x) => x.segment);
|
|
47
|
+
if (arr.length <= max) return s;
|
|
48
|
+
return arr.slice(0, max - 1).join('') + '…';
|
|
49
|
+
}
|
|
50
|
+
let cut = max - 1;
|
|
51
|
+
const c = s.charCodeAt(cut - 1);
|
|
52
|
+
if (c >= 0xd800 && c <= 0xdbff) cut -= 1;
|
|
53
|
+
return s.slice(0, cut) + '…';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Clamp a utilization value to an integer 0–100 (the only dynamic attribute). */
|
|
57
|
+
export function clampPct(value) {
|
|
58
|
+
const n = Number(value);
|
|
59
|
+
if (!Number.isFinite(n)) return 0;
|
|
60
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const STATUS_WORDS = {
|
|
64
|
+
active: 'Active',
|
|
65
|
+
warming: 'Warming up',
|
|
66
|
+
'rate-limited': 'At its limit',
|
|
67
|
+
'needs-reauth': 'Needs sign-in',
|
|
68
|
+
disabled: 'Disabled',
|
|
69
|
+
};
|
|
70
|
+
export function friendlyStatus(status) {
|
|
71
|
+
return STATUS_WORDS[typeof status === 'string' ? status : ''] || 'Unknown';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PROVIDER_WORDS = { anthropic: 'Claude', openai: 'Codex', 'github-copilot': 'Copilot', google: 'Gemini' };
|
|
75
|
+
export function friendlyProvider(provider) {
|
|
76
|
+
return PROVIDER_WORDS[typeof provider === 'string' ? provider : ''] || sanitizeForDisplay(provider, 'label');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Human countdown to a reset/expiry instant: "resets in 2h 15m" / "expired". */
|
|
80
|
+
export function countdown(iso, now = Date.now(), { expiredWord = 'expired' } = {}) {
|
|
81
|
+
const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
|
|
82
|
+
if (Number.isNaN(t)) return '';
|
|
83
|
+
const sec = Math.floor((t - now) / 1000);
|
|
84
|
+
if (sec <= 0) return expiredWord;
|
|
85
|
+
const hr = Math.floor(sec / 3600);
|
|
86
|
+
const min = Math.floor((sec % 3600) / 60);
|
|
87
|
+
if (hr >= 24) { const d = Math.floor(hr / 24); return `${d}d ${hr % 24}h`; }
|
|
88
|
+
if (hr >= 1) return `${hr}h ${min}m`;
|
|
89
|
+
if (min >= 1) return `${min}m`;
|
|
90
|
+
return `${sec}s`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── DOM helpers (textContent ONLY — never innerHTML) ────────────────────────
|
|
94
|
+
function el(doc, tag, cls, text) {
|
|
95
|
+
const node = doc.createElement(tag);
|
|
96
|
+
if (cls) node.setAttribute('class', cls); // static literal
|
|
97
|
+
if (text != null) node.textContent = text; // dynamic text → textContent ONLY
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A labelled quota bar. `pct` is clamped to a 0–100 NUMBER before it reaches the
|
|
102
|
+
* only dynamic attribute (style width); the percent text is also from that number. */
|
|
103
|
+
export function quotaBar(doc, label, pct, resetIso, now = Date.now()) {
|
|
104
|
+
const wrap = el(doc, 'div', 'sub-quota');
|
|
105
|
+
const used = clampPct(pct);
|
|
106
|
+
const head = el(doc, 'div', 'sub-quota-head');
|
|
107
|
+
head.appendChild(el(doc, 'span', 'sub-quota-label', sanitizeForDisplay(label, 'label')));
|
|
108
|
+
const resetTxt = resetIso ? countdown(resetIso, now, { expiredWord: 'resetting' }) : '';
|
|
109
|
+
head.appendChild(el(doc, 'span', 'sub-quota-pct', `${used}% used${resetTxt ? ` · resets in ${resetTxt}` : ''}`));
|
|
110
|
+
wrap.appendChild(head);
|
|
111
|
+
const track = el(doc, 'div', 'sub-quota-track');
|
|
112
|
+
const fill = el(doc, 'div', 'sub-quota-fill');
|
|
113
|
+
fill.style.width = `${used}%`; // safe: `used` is a clamped integer
|
|
114
|
+
track.appendChild(fill);
|
|
115
|
+
wrap.appendChild(track);
|
|
116
|
+
return wrap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Per-account rows: nickname, status, provider·framework, 5h + weekly quota bars. */
|
|
120
|
+
export function renderAccounts(doc, target, accounts, now = Date.now()) {
|
|
121
|
+
if (!target) return;
|
|
122
|
+
target.replaceChildren();
|
|
123
|
+
if (!Array.isArray(accounts) || accounts.length === 0) {
|
|
124
|
+
target.appendChild(el(doc, 'div', 'sub-empty', 'No subscription accounts enrolled yet.'));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const a of accounts) {
|
|
128
|
+
const card = el(doc, 'div', 'sub-account');
|
|
129
|
+
const head = el(doc, 'div', 'sub-account-head');
|
|
130
|
+
head.appendChild(el(doc, 'span', 'sub-account-nick', sanitizeForDisplay(a && a.nickname, 'label')));
|
|
131
|
+
head.appendChild(el(doc, 'span', 'sub-account-status', friendlyStatus(a && a.status)));
|
|
132
|
+
card.appendChild(head);
|
|
133
|
+
card.appendChild(el(doc, 'div', 'sub-account-meta',
|
|
134
|
+
`${friendlyProvider(a && a.provider)} · ${sanitizeForDisplay(a && a.framework, 'label')}`));
|
|
135
|
+
const q = (a && a.lastQuota) || null;
|
|
136
|
+
if (q && (q.fiveHour || q.sevenDay)) {
|
|
137
|
+
if (q.fiveHour) card.appendChild(quotaBar(doc, '5-hour', q.fiveHour.utilizationPct, q.fiveHour.resetsAt, now));
|
|
138
|
+
if (q.sevenDay) card.appendChild(quotaBar(doc, 'Weekly', q.sevenDay.utilizationPct, q.sevenDay.resetsAt, now));
|
|
139
|
+
} else {
|
|
140
|
+
card.appendChild(el(doc, 'div', 'sub-account-noquota', 'No quota reading yet.'));
|
|
141
|
+
}
|
|
142
|
+
target.appendChild(card);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Pending Logins panel: device code / verification URL (as TEXT) + TTL + reissues. */
|
|
147
|
+
export function renderPendingLogins(doc, target, logins, now = Date.now()) {
|
|
148
|
+
if (!target) return;
|
|
149
|
+
target.replaceChildren();
|
|
150
|
+
if (!Array.isArray(logins) || logins.length === 0) {
|
|
151
|
+
target.appendChild(el(doc, 'div', 'sub-empty', 'No logins waiting for approval.'));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const l of logins) {
|
|
155
|
+
const row = el(doc, 'div', 'sub-pending');
|
|
156
|
+
const head = el(doc, 'div', 'sub-pending-head');
|
|
157
|
+
head.appendChild(el(doc, 'span', 'sub-pending-label', sanitizeForDisplay(l && l.label, 'label')));
|
|
158
|
+
const ttl = l && l.ttlExpiresAt ? countdown(l.ttlExpiresAt, now) : '';
|
|
159
|
+
head.appendChild(el(doc, 'span', 'sub-pending-ttl', ttl ? `expires in ${ttl}` : 'expired'));
|
|
160
|
+
row.appendChild(head);
|
|
161
|
+
if (l && l.userCode) {
|
|
162
|
+
row.appendChild(el(doc, 'div', 'sub-pending-code', `Code: ${sanitizeForDisplay(l.userCode, 'code')}`));
|
|
163
|
+
}
|
|
164
|
+
// Verification URL shown as TEXT for the operator to copy — never a live href.
|
|
165
|
+
row.appendChild(el(doc, 'div', 'sub-pending-url', sanitizeForDisplay(l && l.verificationUrl, 'url')));
|
|
166
|
+
const rc = l && Number(l.reissueCount);
|
|
167
|
+
if (Number.isFinite(rc) && rc > 0) {
|
|
168
|
+
row.appendChild(el(doc, 'div', 'sub-pending-reissue', `Re-issued ${rc} time${rc === 1 ? '' : 's'}`));
|
|
169
|
+
}
|
|
170
|
+
target.appendChild(row);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function renderDisabled(doc, els) {
|
|
175
|
+
if (els && els.accounts) {
|
|
176
|
+
els.accounts.replaceChildren(
|
|
177
|
+
el(doc, 'div', 'sub-disabled', 'The subscription pool isn’t set up yet. Enroll an account to get started.'),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if (els && els.pending) els.pending.replaceChildren();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Controller (fetch /subscription-pool + /pending-logins, render) ─────────
|
|
184
|
+
const URLS = {
|
|
185
|
+
accounts: '/subscription-pool',
|
|
186
|
+
pending: '/subscription-pool/pending-logins',
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export function createController(opts) {
|
|
190
|
+
const {
|
|
191
|
+
doc,
|
|
192
|
+
els = {},
|
|
193
|
+
fetchImpl,
|
|
194
|
+
now = () => Date.now(),
|
|
195
|
+
cadenceMs = 30_000,
|
|
196
|
+
schedule = (fn, ms) => setTimeout(fn, ms),
|
|
197
|
+
cancel = (id) => clearTimeout(id),
|
|
198
|
+
} = opts;
|
|
199
|
+
|
|
200
|
+
const state = { timerId: null, active: false, inFlight: null };
|
|
201
|
+
|
|
202
|
+
async function fetchJson(url, controller) {
|
|
203
|
+
const resp = await fetchImpl(url, { signal: controller.signal });
|
|
204
|
+
if (!resp.ok) throw new Error(`${url} ${resp.status}`);
|
|
205
|
+
return resp.json();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function tick() {
|
|
209
|
+
if (!state.active) return;
|
|
210
|
+
if (state.inFlight) { try { state.inFlight.abort(); } catch { /* superseded */ } }
|
|
211
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : { signal: undefined, abort() {} };
|
|
212
|
+
state.inFlight = controller;
|
|
213
|
+
let accountsBody, pendingBody;
|
|
214
|
+
try {
|
|
215
|
+
[accountsBody, pendingBody] = await Promise.all([
|
|
216
|
+
fetchJson(URLS.accounts, controller),
|
|
217
|
+
fetchJson(URLS.pending, controller),
|
|
218
|
+
]);
|
|
219
|
+
} catch {
|
|
220
|
+
if (controller.signal && controller.signal.aborted) return;
|
|
221
|
+
state.inFlight = null;
|
|
222
|
+
reschedule();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (controller.signal && controller.signal.aborted) return;
|
|
226
|
+
state.inFlight = null;
|
|
227
|
+
// Feature dark → both routes answer { enabled:false }. Show the friendly copy.
|
|
228
|
+
if (accountsBody && accountsBody.enabled === false && pendingBody && pendingBody.enabled === false) {
|
|
229
|
+
renderDisabled(doc, els);
|
|
230
|
+
reschedule();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
render(accountsBody, pendingBody);
|
|
234
|
+
reschedule();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function render(accountsBody, pendingBody) {
|
|
238
|
+
const accounts = accountsBody && Array.isArray(accountsBody.accounts) ? accountsBody.accounts : [];
|
|
239
|
+
const logins = pendingBody && Array.isArray(pendingBody.logins) ? pendingBody.logins : [];
|
|
240
|
+
renderAccounts(doc, els.accounts, accounts, now());
|
|
241
|
+
renderPendingLogins(doc, els.pending, logins, now());
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function reschedule() {
|
|
245
|
+
if (!state.active) return;
|
|
246
|
+
if (state.timerId != null) cancel(state.timerId);
|
|
247
|
+
state.timerId = schedule(() => { void tick(); }, cadenceMs);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function start() { if (state.active) return; state.active = true; void tick(); }
|
|
251
|
+
function stop() {
|
|
252
|
+
state.active = false;
|
|
253
|
+
if (state.timerId != null) { cancel(state.timerId); state.timerId = null; }
|
|
254
|
+
if (state.inFlight) { try { state.inFlight.abort(); } catch { /* ignore */ } state.inFlight = null; }
|
|
255
|
+
}
|
|
256
|
+
function onVisible() { if (!state.active) start(); }
|
|
257
|
+
function onHidden() { stop(); }
|
|
258
|
+
|
|
259
|
+
return { start, stop, onVisible, onHidden, tick, render, _state: state };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof window !== 'undefined') {
|
|
263
|
+
window.Subscriptions = { createController, sanitizeForDisplay, renderAccounts, renderPendingLogins };
|
|
264
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-07T08:
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-07T08:45:36.629Z",
|
|
5
|
+
"instarVersion": "1.3.403",
|
|
6
6
|
"entryCount": 199,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: minor -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
Added a **Subscriptions** tab to the dashboard — the visual surface for the
|
|
9
|
+
multi-account Subscription & Auth pool (P1.1–P2.1). It shows, per account: the
|
|
10
|
+
nickname, a friendly status, and live quota bars for the 5-hour and weekly
|
|
11
|
+
windows with a "resets in …" countdown. A **Pending Logins** panel below lists
|
|
12
|
+
any in-flight enrollment — the device code / verification URL (shown as copyable
|
|
13
|
+
text, never a live link) with its TTL countdown and how many times it has been
|
|
14
|
+
re-issued.
|
|
15
|
+
|
|
16
|
+
The tab is a self-contained browser-native ESM module (`dashboard/subscriptions.js`)
|
|
17
|
+
served statically, registered in `index.html` exactly like the Process Health and
|
|
18
|
+
Preferences tabs (lazy dynamic-import, start/stop on tab activation, polled every
|
|
19
|
+
30s). It consumes the existing `GET /subscription-pool` and
|
|
20
|
+
`GET /subscription-pool/pending-logins` routes — no new server routes, no `src/`
|
|
21
|
+
change. When the pool isn't set up the routes answer `{ enabled:false }` and the
|
|
22
|
+
tab shows a friendly "not set up yet" message (never an error).
|
|
23
|
+
|
|
24
|
+
It carries the same load-bearing display-safety contract as the Process Health
|
|
25
|
+
tab: every dynamic value is sanitized (NFKC fold + control/bidi/chrome-glyph
|
|
26
|
+
strip + grapheme cap) before the DOM, all writes are `textContent` only, and the
|
|
27
|
+
only dynamic attribute (a quota-bar width) comes from a clamped 0–100 integer.
|
|
28
|
+
|
|
29
|
+
Coverage: 26 tests across three tiers — render unit tests (sanitize/clamp/
|
|
30
|
+
countdown/renderers + XSS-survives-as-inert-text), a controller integration test
|
|
31
|
+
(jsdom + injected fetch/timers: render, feature-dark, XSS through the controller,
|
|
32
|
+
fetch-failure resilience, visibility gating), and an e2e feature-alive test that
|
|
33
|
+
boots a real server with the production routes and drives the shipped controller
|
|
34
|
+
against it (accounts + pending logins render; feature-off → friendly copy).
|
|
35
|
+
|
|
36
|
+
## What to Tell Your User
|
|
37
|
+
|
|
38
|
+
There's a new **Subscriptions** tab on your dashboard. It shows each of your
|
|
39
|
+
subscription accounts, how much of each is left (5-hour and weekly) and when it
|
|
40
|
+
resets, and any logins still waiting for you to approve on your phone — with the
|
|
41
|
+
code and a countdown. Open it from any device with your dashboard PIN.
|
|
42
|
+
|
|
43
|
+
## Summary of New Capabilities
|
|
44
|
+
|
|
45
|
+
- **Subscriptions dashboard tab** — per-account live quota bars (5h + weekly) with
|
|
46
|
+
reset countdowns + friendly status, on any device behind the dashboard PIN.
|
|
47
|
+
- **Pending Logins panel** — in-flight enrollment codes / verification URLs (as
|
|
48
|
+
copyable text) with TTL countdown + re-issue count.
|
|
49
|
+
- **Mobile-responsive + safe** — sanitized, `textContent`-only rendering; no live
|
|
50
|
+
links; reuses the Process Health tab's hardened display contract.
|