instar 1.3.618 → 1.3.620
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 +16 -1
- package/dashboard/subscriptions.js +151 -6
- package/dist/coordination/AccountFollowMeOperatorOutbox.d.ts +42 -0
- package/dist/coordination/AccountFollowMeOperatorOutbox.d.ts.map +1 -0
- package/dist/coordination/AccountFollowMeOperatorOutbox.js +81 -0
- package/dist/coordination/AccountFollowMeOperatorOutbox.js.map +1 -0
- package/dist/coordination/AccountFollowMeSingleFlight.d.ts +68 -0
- package/dist/coordination/AccountFollowMeSingleFlight.d.ts.map +1 -0
- package/dist/coordination/AccountFollowMeSingleFlight.js +135 -0
- package/dist/coordination/AccountFollowMeSingleFlight.js.map +1 -0
- package/dist/coordination/driveFollowMeEnrollment.d.ts +80 -0
- package/dist/coordination/driveFollowMeEnrollment.d.ts.map +1 -0
- package/dist/coordination/driveFollowMeEnrollment.js +64 -0
- package/dist/coordination/driveFollowMeEnrollment.js.map +1 -0
- package/dist/coordination/enrollPointOfUseCheck.d.ts +48 -0
- package/dist/coordination/enrollPointOfUseCheck.d.ts.map +1 -0
- package/dist/coordination/enrollPointOfUseCheck.js +45 -0
- package/dist/coordination/enrollPointOfUseCheck.js.map +1 -0
- package/dist/coordination/followMeConsumerSweep.d.ts +32 -0
- package/dist/coordination/followMeConsumerSweep.d.ts.map +1 -0
- package/dist/coordination/followMeConsumerSweep.js +58 -0
- package/dist/coordination/followMeConsumerSweep.js.map +1 -0
- package/dist/core/rawTextRequestDetector.d.ts +27 -0
- package/dist/core/rawTextRequestDetector.d.ts.map +1 -0
- package/dist/core/rawTextRequestDetector.js +42 -0
- package/dist/core/rawTextRequestDetector.js.map +1 -0
- package/dist/monitoring/guardManifest.d.ts.map +1 -1
- package/dist/monitoring/guardManifest.js +1 -0
- package/dist/monitoring/guardManifest.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +70 -4
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/instar-dev-precommit.js +38 -1
- package/scripts/lib/operator-surface.mjs +63 -0
- package/src/data/builtin-manifest.json +46 -46
- package/upgrades/1.3.619.md +33 -0
- package/upgrades/1.3.620.md +22 -0
- package/upgrades/side-effects/ws52-operator-card-wiring.md +60 -0
- package/upgrades/side-effects/ws52-operator-tap-not-text.md +112 -0
package/dashboard/index.html
CHANGED
|
@@ -3397,6 +3397,10 @@
|
|
|
3397
3397
|
<div id="subscriptionsPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
|
|
3398
3398
|
<h2 class="ph-title">Subscriptions</h2>
|
|
3399
3399
|
<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>
|
|
3400
|
+
<!-- One-tap follow-me Approve card(s) — rendered only when another machine could use one of
|
|
3401
|
+
your subscriptions (ws52-operator-tap-not-text Part A). Leads the panel as the primary
|
|
3402
|
+
action when present; silent otherwise. -->
|
|
3403
|
+
<div id="subFollowMe"></div>
|
|
3400
3404
|
<section class="ph-section">
|
|
3401
3405
|
<h3 class="ph-h">Accounts</h3>
|
|
3402
3406
|
<p class="ph-h-sub">Each account’s live usage (5-hour and weekly) and when it resets.</p>
|
|
@@ -3429,6 +3433,14 @@
|
|
|
3429
3433
|
<h3 class="ph-h">Issued mandates</h3>
|
|
3430
3434
|
<div id="mndList"></div>
|
|
3431
3435
|
</section>
|
|
3436
|
+
<!-- operator-surface-power-user: this dashboard file contains DELIBERATELY-ADVANCED,
|
|
3437
|
+
non-default surfaces that necessarily take raw technical values — the collapsed
|
|
3438
|
+
"Advanced — author a mandate by hand" mandate form below (raw agent fingerprints +
|
|
3439
|
+
the authorities the agents may use) and the Threadline bridge fingerprint/name inputs.
|
|
3440
|
+
These are NOT the default operator path: account-follow-me is the one-tap Approve card
|
|
3441
|
+
(Subscriptions tab) and general grants go through the Agent-Proposes/Operator-Approves
|
|
3442
|
+
approval surface. (Marker is file-level by the gate's design; granular per-region marking
|
|
3443
|
+
+ redesigning this legacy raw form to a builder are tracked follow-ups.) -->
|
|
3432
3444
|
<details class="ph-detail" id="mndIssueDetails">
|
|
3433
3445
|
<summary class="ph-detail-summary">Advanced — author a mandate by hand (PIN required)</summary>
|
|
3434
3446
|
<div class="mnd-issue-form">
|
|
@@ -9144,10 +9156,13 @@
|
|
|
9144
9156
|
const els = {
|
|
9145
9157
|
accounts: document.getElementById('subAccounts'),
|
|
9146
9158
|
pending: document.getElementById('subPending'),
|
|
9159
|
+
followMe: document.getElementById('subFollowMe'),
|
|
9147
9160
|
};
|
|
9161
|
+
// Pass opts through (method/body/signal) so the controller can POST the follow-me
|
|
9162
|
+
// scan + the PIN-gated issue-for-machine, not just GET.
|
|
9148
9163
|
const fetchImpl = (url, opts = {}) => fetch(url, {
|
|
9164
|
+
...opts,
|
|
9149
9165
|
headers: { 'Authorization': `Bearer ${token}`, ...(opts.headers || {}) },
|
|
9150
|
-
signal: opts.signal,
|
|
9151
9166
|
});
|
|
9152
9167
|
__subController = __subModule.createController({ doc: document, els, fetchImpl });
|
|
9153
9168
|
}
|
|
@@ -172,6 +172,80 @@ export function renderAccounts(doc, target, accounts, now = Date.now(), inUseAcc
|
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Account Follow-Me — the ONE-TAP Approve card (ws52-operator-tap-not-text Part A).
|
|
177
|
+
* Renders a scan consent-offer as a plain-language card the operator APPROVES with a
|
|
178
|
+
* single PIN tap — never a JSON/fingerprint paste (the 2026-06-17 operator feedback:
|
|
179
|
+
* "operators act in taps, not text"). All operator-facing values are sanitized TEXT;
|
|
180
|
+
* the only machine-readable data on the card are the NON-SENSITIVE account/target ids
|
|
181
|
+
* as data-* attributes (used to find the offer on Approve). The agent fingerprints
|
|
182
|
+
* (FD2) are NEVER placed in the DOM — they live in the controller's offer state and
|
|
183
|
+
* are sent server-side at POST time. By construction this card carries zero raw
|
|
184
|
+
* technical text, so it PASSES the arm-1 operator-surface gate.
|
|
185
|
+
*/
|
|
186
|
+
export function renderFollowMeApproveCard(doc, offer) {
|
|
187
|
+
const card = el(doc, 'div', 'sub-followme-offer');
|
|
188
|
+
// Non-sensitive identifiers, for the Approve handler to resolve the offer. NOT
|
|
189
|
+
// operator-facing text; never a fingerprint/JSON.
|
|
190
|
+
card.setAttribute('data-account-id', sanitizeForDisplay(offer && offer.accountId, 'label'));
|
|
191
|
+
card.setAttribute('data-target-machine-id', sanitizeForDisplay(offer && offer.targetMachineId, 'label'));
|
|
192
|
+
|
|
193
|
+
const machine = sanitizeForDisplay(offer && offer.machineNickname, 'label');
|
|
194
|
+
const account = sanitizeForDisplay(offer && offer.accountLabel, 'label');
|
|
195
|
+
card.appendChild(el(doc, 'div', 'sub-followme-headline',
|
|
196
|
+
`Let ${machine} use your ${account} subscription`));
|
|
197
|
+
card.appendChild(el(doc, 'div', 'sub-followme-sub',
|
|
198
|
+
sanitizeForDisplay(offer && offer.expiryText, 'summary') || 'Authorizes this one setup, then expires.'));
|
|
199
|
+
|
|
200
|
+
const pin = doc.createElement('input');
|
|
201
|
+
pin.setAttribute('type', 'password');
|
|
202
|
+
pin.setAttribute('class', 'sub-followme-pin');
|
|
203
|
+
pin.setAttribute('placeholder', 'Your PIN'); // a PIN box, not a technical value
|
|
204
|
+
pin.setAttribute('autocomplete', 'off');
|
|
205
|
+
card.appendChild(pin);
|
|
206
|
+
|
|
207
|
+
const approve = el(doc, 'button', 'sub-followme-approve', 'Approve');
|
|
208
|
+
approve.setAttribute('data-followme-approve', '1');
|
|
209
|
+
card.appendChild(approve);
|
|
210
|
+
return card;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Render the list of follow-me consent offers as one-tap Approve cards (or nothing if none). */
|
|
214
|
+
export function renderFollowMeOffers(doc, target, offers) {
|
|
215
|
+
if (!target) return;
|
|
216
|
+
target.replaceChildren();
|
|
217
|
+
if (!Array.isArray(offers) || offers.length === 0) return; // silent when nothing to offer
|
|
218
|
+
target.appendChild(el(doc, 'div', 'sub-followme-title', 'Let another machine use a subscription'));
|
|
219
|
+
for (const offer of offers) target.appendChild(renderFollowMeApproveCard(doc, offer));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build the /mandate/issue-for-machine payload from a tapped Approve card + the
|
|
224
|
+
* held offers + the operator's PIN (ws52-operator-tap-not-text Part A). Pure: the
|
|
225
|
+
* card carries only the non-sensitive account/target ids; the agent fingerprints
|
|
226
|
+
* (FD2) come from the matched offer in controller state — the operator never typed
|
|
227
|
+
* them. Returns the payload, or `{ error }` for a missing PIN, or null when the
|
|
228
|
+
* card has no matching offer / the offer lacks its FD2 agent pair (fail-closed —
|
|
229
|
+
* never POST an under-specified mandate request).
|
|
230
|
+
*/
|
|
231
|
+
export function buildFollowMeIssuePayload(card, offers, pinValue) {
|
|
232
|
+
if (!card || typeof card.getAttribute !== 'function') return null;
|
|
233
|
+
const accountId = card.getAttribute('data-account-id');
|
|
234
|
+
const targetMachineId = card.getAttribute('data-target-machine-id');
|
|
235
|
+
if (!accountId || !targetMachineId) return null;
|
|
236
|
+
const offer = (Array.isArray(offers) ? offers : []).find(
|
|
237
|
+
(o) => o && o.accountId === accountId && o.targetMachineId === targetMachineId,
|
|
238
|
+
);
|
|
239
|
+
if (!offer) return null; // unknown/stale card — never POST
|
|
240
|
+
if (!Array.isArray(offer.agents) || offer.agents.length !== 2
|
|
241
|
+
|| offer.agents.some((a) => typeof a !== 'string' || !a)) {
|
|
242
|
+
return null; // FD2 agent pair missing — fail-closed
|
|
243
|
+
}
|
|
244
|
+
const pin = typeof pinValue === 'string' ? pinValue.trim() : '';
|
|
245
|
+
if (!pin) return { error: 'pin-required' };
|
|
246
|
+
return { pin, accountId, targetMachineId, agents: [offer.agents[0], offer.agents[1]] };
|
|
247
|
+
}
|
|
248
|
+
|
|
175
249
|
/** Pending Logins panel: device code / verification URL (as TEXT) + TTL + reissues. */
|
|
176
250
|
export function renderPendingLogins(doc, target, logins, now = Date.now()) {
|
|
177
251
|
if (!target) return;
|
|
@@ -219,6 +293,8 @@ const URLS = {
|
|
|
219
293
|
accounts: '/subscription-pool',
|
|
220
294
|
pending: '/subscription-pool/pending-logins',
|
|
221
295
|
inUse: '/subscription-pool/in-use',
|
|
296
|
+
scan: '/subscription-pool/follow-me/scan', // POST — follow-me consent offers (one-tap card)
|
|
297
|
+
issue: '/mandate/issue-for-machine', // POST (PIN-gated) — Approve issues the mandate
|
|
222
298
|
};
|
|
223
299
|
|
|
224
300
|
export function createController(opts) {
|
|
@@ -232,7 +308,7 @@ export function createController(opts) {
|
|
|
232
308
|
cancel = (id) => clearTimeout(id),
|
|
233
309
|
} = opts;
|
|
234
310
|
|
|
235
|
-
const state = { timerId: null, active: false, inFlight: null };
|
|
311
|
+
const state = { timerId: null, active: false, inFlight: null, offers: [], approveWired: false };
|
|
236
312
|
|
|
237
313
|
async function fetchJson(url, controller) {
|
|
238
314
|
const resp = await fetchImpl(url, { signal: controller.signal });
|
|
@@ -240,19 +316,34 @@ export function createController(opts) {
|
|
|
240
316
|
return resp.json();
|
|
241
317
|
}
|
|
242
318
|
|
|
319
|
+
// POST helper (follow-me scan + the PIN-gated issue both POST). Best-effort callers
|
|
320
|
+
// catch their own failures so a follow-me hiccup never blanks the accounts list.
|
|
321
|
+
async function postJson(url, body, controller) {
|
|
322
|
+
const resp = await fetchImpl(url, {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { 'Content-Type': 'application/json' },
|
|
325
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
326
|
+
signal: controller && controller.signal,
|
|
327
|
+
});
|
|
328
|
+
let json = null;
|
|
329
|
+
try { json = await resp.json(); } catch { /* may be empty */ }
|
|
330
|
+
return { ok: resp.ok, status: resp.status, json };
|
|
331
|
+
}
|
|
332
|
+
|
|
243
333
|
async function tick() {
|
|
244
334
|
if (!state.active) return;
|
|
245
335
|
if (state.inFlight) { try { state.inFlight.abort(); } catch { /* superseded */ } }
|
|
246
336
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : { signal: undefined, abort() {} };
|
|
247
337
|
state.inFlight = controller;
|
|
248
|
-
let accountsBody, pendingBody, inUseBody;
|
|
338
|
+
let accountsBody, pendingBody, inUseBody, scanBody;
|
|
249
339
|
try {
|
|
250
|
-
// in-use
|
|
251
|
-
//
|
|
252
|
-
[accountsBody, pendingBody, inUseBody] = await Promise.all([
|
|
340
|
+
// in-use AND the follow-me scan are best-effort — their failure must not blank the
|
|
341
|
+
// accounts list, so each is caught independently (in-use → "unknown"; scan → no card).
|
|
342
|
+
[accountsBody, pendingBody, inUseBody, scanBody] = await Promise.all([
|
|
253
343
|
fetchJson(URLS.accounts, controller),
|
|
254
344
|
fetchJson(URLS.pending, controller),
|
|
255
345
|
fetchJson(URLS.inUse, controller).catch(() => null),
|
|
346
|
+
postJson(URLS.scan, {}, controller).then((r) => (r.ok ? r.json : null)).catch(() => null),
|
|
256
347
|
]);
|
|
257
348
|
} catch {
|
|
258
349
|
if (controller.signal && controller.signal.aborted) return;
|
|
@@ -268,6 +359,7 @@ export function createController(opts) {
|
|
|
268
359
|
reschedule();
|
|
269
360
|
return;
|
|
270
361
|
}
|
|
362
|
+
state.offers = scanBody && Array.isArray(scanBody.offered) ? scanBody.offered : [];
|
|
271
363
|
render(accountsBody, pendingBody, inUseBody);
|
|
272
364
|
reschedule();
|
|
273
365
|
}
|
|
@@ -278,6 +370,56 @@ export function createController(opts) {
|
|
|
278
370
|
const inUseAccountId = inUseBody && inUseBody.activeAccountId ? inUseBody.activeAccountId : null;
|
|
279
371
|
renderAccounts(doc, els.accounts, accounts, now(), inUseAccountId);
|
|
280
372
|
renderPendingLogins(doc, els.pending, logins, now());
|
|
373
|
+
// The one-tap follow-me Approve card(s) — rendered into els.followMe from the scan offers
|
|
374
|
+
// (ws52-operator-tap-not-text Part A). Silent when there are none. The Approve click is wired
|
|
375
|
+
// once (delegated) so re-renders never stack listeners.
|
|
376
|
+
if (els.followMe) {
|
|
377
|
+
renderFollowMeOffers(doc, els.followMe, state.offers);
|
|
378
|
+
wireApprove();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Delegated, wired ONCE: an Approve tap reads the card's PIN, builds the issue-for-machine
|
|
383
|
+
// payload from the held offers (FD2 agents resolved server-side — never typed), and POSTs the
|
|
384
|
+
// PIN-gated mandate. The card carries only non-sensitive ids; the PIN is sent once, never stored.
|
|
385
|
+
function wireApprove() {
|
|
386
|
+
if (state.approveWired || !els.followMe) return;
|
|
387
|
+
state.approveWired = true;
|
|
388
|
+
els.followMe.addEventListener('click', (ev) => {
|
|
389
|
+
const btn = ev.target && ev.target.closest ? ev.target.closest('[data-followme-approve]') : null;
|
|
390
|
+
if (!btn || !els.followMe.contains(btn)) return;
|
|
391
|
+
const card = btn.closest('.sub-followme-offer');
|
|
392
|
+
if (!card) return;
|
|
393
|
+
const pinInput = card.querySelector('.sub-followme-pin');
|
|
394
|
+
const pinVal = pinInput ? pinInput.value : '';
|
|
395
|
+
const payload = buildFollowMeIssuePayload(card, state.offers, pinVal);
|
|
396
|
+
if (payload === null) { setCardStatus(card, 'Couldn’t prepare this request — please refresh.'); return; }
|
|
397
|
+
if (payload.error === 'pin-required') { setCardStatus(card, 'Enter your PIN to approve.'); return; }
|
|
398
|
+
setCardStatus(card, 'Approving…');
|
|
399
|
+
btn.setAttribute('disabled', '1');
|
|
400
|
+
void (async () => {
|
|
401
|
+
try {
|
|
402
|
+
const r = await postJson(URLS.issue, payload);
|
|
403
|
+
if (r.ok) {
|
|
404
|
+
setCardStatus(card, 'Approved — the machine is logging in now. Watch the “Logins waiting” panel below for the link to tap.');
|
|
405
|
+
if (pinInput) pinInput.value = '';
|
|
406
|
+
} else {
|
|
407
|
+
const msg = r.json && (r.json.error || r.json.reason) ? (r.json.error || r.json.reason) : `failed (${r.status})`;
|
|
408
|
+
setCardStatus(card, `Couldn’t approve: ${msg}`);
|
|
409
|
+
btn.removeAttribute('disabled');
|
|
410
|
+
}
|
|
411
|
+
} catch (e) {
|
|
412
|
+
setCardStatus(card, 'Couldn’t reach the server — try again.');
|
|
413
|
+
btn.removeAttribute('disabled');
|
|
414
|
+
}
|
|
415
|
+
})();
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function setCardStatus(card, text) {
|
|
420
|
+
let s = card.querySelector('.sub-followme-status');
|
|
421
|
+
if (!s) { s = el(doc, 'div', 'sub-followme-status', ''); card.appendChild(s); }
|
|
422
|
+
s.textContent = text;
|
|
281
423
|
}
|
|
282
424
|
|
|
283
425
|
function reschedule() {
|
|
@@ -299,5 +441,8 @@ export function createController(opts) {
|
|
|
299
441
|
}
|
|
300
442
|
|
|
301
443
|
if (typeof window !== 'undefined') {
|
|
302
|
-
window.Subscriptions = {
|
|
444
|
+
window.Subscriptions = {
|
|
445
|
+
createController, sanitizeForDisplay, renderAccounts, renderPendingLogins,
|
|
446
|
+
renderFollowMeOffers, renderFollowMeApproveCard, buildFollowMeIssuePayload,
|
|
447
|
+
};
|
|
303
448
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface OperatorOutboxRecord {
|
|
2
|
+
/** `${ledgerKey}::${state}` — the dedup unit (one message per ledger state). */
|
|
3
|
+
dedupKey: string;
|
|
4
|
+
/** The event that first emitted for this (ledgerKey,state) — audit + idempotency. */
|
|
5
|
+
eventId: string;
|
|
6
|
+
ledgerKey: string;
|
|
7
|
+
state: string;
|
|
8
|
+
emittedAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface OperatorOutboxDeps {
|
|
11
|
+
filePath: string;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
export declare class AccountFollowMeOperatorOutbox {
|
|
15
|
+
private readonly d;
|
|
16
|
+
constructor(deps: OperatorOutboxDeps);
|
|
17
|
+
private now;
|
|
18
|
+
private dedupKey;
|
|
19
|
+
private readAll;
|
|
20
|
+
private writeAll;
|
|
21
|
+
/**
|
|
22
|
+
* Atomically decide whether to emit an operator message for this (ledgerKey,state).
|
|
23
|
+
* Returns `{ emit:true }` exactly once per (ledgerKey,state) — the FIRST caller
|
|
24
|
+
* records and emits; every subsequent caller (redelivery/restart/retry, regardless
|
|
25
|
+
* of eventId) gets `{ emit:false }`. This is the "at most one message per ledger
|
|
26
|
+
* state" guarantee.
|
|
27
|
+
*/
|
|
28
|
+
claimEmit(args: {
|
|
29
|
+
ledgerKey: string;
|
|
30
|
+
state: string;
|
|
31
|
+
eventId: string;
|
|
32
|
+
}): {
|
|
33
|
+
emit: boolean;
|
|
34
|
+
firstEventId: string;
|
|
35
|
+
};
|
|
36
|
+
/** Has a message already been emitted for this (ledgerKey,state)? (read-only) */
|
|
37
|
+
hasEmitted(ledgerKey: string, state: string): boolean;
|
|
38
|
+
/** Drop all outbox records for a ledger key (e.g. when the pair is removed/revoked). Idempotent. */
|
|
39
|
+
clearLedger(ledgerKey: string): void;
|
|
40
|
+
list(): OperatorOutboxRecord[];
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=AccountFollowMeOperatorOutbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccountFollowMeOperatorOutbox.d.ts","sourceRoot":"","sources":["../../src/coordination/AccountFollowMeOperatorOutbox.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,oBAAoB;IACnC,gFAAgF;IAChF,QAAQ,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,qBAAa,6BAA6B;IACxC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAqB;gBAC3B,IAAI,EAAE,kBAAkB;IAIpC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,OAAO;IAUf,OAAO,CAAC,QAAQ;IAKhB;;;;;;OAMG;IACH,SAAS,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG;QACtE,IAAI,EAAE,OAAO,CAAC;QACd,YAAY,EAAE,MAAM,CAAC;KACtB;IAkBD,iFAAiF;IACjF,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAIrD,oGAAoG;IACpG,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAMpC,IAAI,IAAI,oBAAoB,EAAE;CAG/B"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS5.2 (ws52-operator-tap-not-text Part B, R3.3) — durable operator-message outbox.
|
|
3
|
+
*
|
|
4
|
+
* The cross-model pass (codex) flagged that "exactly one tappable link / one failure
|
|
5
|
+
* message" is unachievable across mesh redelivery, fronting-machine restart, and
|
|
6
|
+
* retry WITHOUT a durable, idempotent outbox. This store guarantees AT MOST ONE
|
|
7
|
+
* visible operator message PER LEDGER STATE: the first claim for a
|
|
8
|
+
* (ledgerKey, state) pair emits; every later claim for the SAME (ledgerKey, state)
|
|
9
|
+
* — a redelivery, a restart replay, a retry — is suppressed. A genuine state
|
|
10
|
+
* TRANSITION (enroll-in-flight → login-issued → failed) is a new (ledgerKey, state)
|
|
11
|
+
* key, so each distinct state surfaces its own single message.
|
|
12
|
+
*
|
|
13
|
+
* fs-backed (the DeliveredMandateStore pattern), pure + unit-testable.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export class AccountFollowMeOperatorOutbox {
|
|
18
|
+
d;
|
|
19
|
+
constructor(deps) {
|
|
20
|
+
this.d = deps;
|
|
21
|
+
}
|
|
22
|
+
now() {
|
|
23
|
+
return this.d.now ? this.d.now() : Date.now();
|
|
24
|
+
}
|
|
25
|
+
dedupKey(ledgerKey, state) {
|
|
26
|
+
return `${ledgerKey}::${state}`;
|
|
27
|
+
}
|
|
28
|
+
readAll() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(fs.readFileSync(this.d.filePath, 'utf8'));
|
|
31
|
+
return Array.isArray(raw) ? raw : [];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// @silent-fallback-ok — no outbox yet ⇒ nothing emitted ⇒ first claim should emit.
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
writeAll(records) {
|
|
39
|
+
fs.mkdirSync(path.dirname(this.d.filePath), { recursive: true });
|
|
40
|
+
fs.writeFileSync(this.d.filePath, JSON.stringify(records, null, 2));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Atomically decide whether to emit an operator message for this (ledgerKey,state).
|
|
44
|
+
* Returns `{ emit:true }` exactly once per (ledgerKey,state) — the FIRST caller
|
|
45
|
+
* records and emits; every subsequent caller (redelivery/restart/retry, regardless
|
|
46
|
+
* of eventId) gets `{ emit:false }`. This is the "at most one message per ledger
|
|
47
|
+
* state" guarantee.
|
|
48
|
+
*/
|
|
49
|
+
claimEmit(args) {
|
|
50
|
+
const key = this.dedupKey(args.ledgerKey, args.state);
|
|
51
|
+
const all = this.readAll();
|
|
52
|
+
const existing = all.find((r) => r.dedupKey === key);
|
|
53
|
+
if (existing) {
|
|
54
|
+
return { emit: false, firstEventId: existing.eventId };
|
|
55
|
+
}
|
|
56
|
+
const record = {
|
|
57
|
+
dedupKey: key,
|
|
58
|
+
eventId: args.eventId,
|
|
59
|
+
ledgerKey: args.ledgerKey,
|
|
60
|
+
state: args.state,
|
|
61
|
+
emittedAt: new Date(this.now()).toISOString(),
|
|
62
|
+
};
|
|
63
|
+
this.writeAll([...all, record]);
|
|
64
|
+
return { emit: true, firstEventId: args.eventId };
|
|
65
|
+
}
|
|
66
|
+
/** Has a message already been emitted for this (ledgerKey,state)? (read-only) */
|
|
67
|
+
hasEmitted(ledgerKey, state) {
|
|
68
|
+
return this.readAll().some((r) => r.dedupKey === this.dedupKey(ledgerKey, state));
|
|
69
|
+
}
|
|
70
|
+
/** Drop all outbox records for a ledger key (e.g. when the pair is removed/revoked). Idempotent. */
|
|
71
|
+
clearLedger(ledgerKey) {
|
|
72
|
+
const all = this.readAll();
|
|
73
|
+
const next = all.filter((r) => r.ledgerKey !== ledgerKey);
|
|
74
|
+
if (next.length !== all.length)
|
|
75
|
+
this.writeAll(next);
|
|
76
|
+
}
|
|
77
|
+
list() {
|
|
78
|
+
return this.readAll();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=AccountFollowMeOperatorOutbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccountFollowMeOperatorOutbox.js","sourceRoot":"","sources":["../../src/coordination/AccountFollowMeOperatorOutbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAiB7B,MAAM,OAAO,6BAA6B;IACvB,CAAC,CAAqB;IACvC,YAAY,IAAwB;QAClC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;IAChB,CAAC;IAEO,GAAG;QACT,OAAO,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC;IAChD,CAAC;IAEO,QAAQ,CAAC,SAAiB,EAAE,KAAa;QAC/C,OAAO,GAAG,SAAS,KAAK,KAAK,EAAE,CAAC;IAClC,CAAC;IAEO,OAAO;QACb,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;YACjE,OAAO,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,GAA8B,CAAC,CAAC,CAAC,EAAE,CAAC;QACnE,CAAC;QAAC,MAAM,CAAC;YACP,mFAAmF;YACnF,OAAO,EAAE,CAAC;QACZ,CAAC;IACH,CAAC;IAEO,QAAQ,CAAC,OAA+B;QAC9C,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACjE,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IACtE,CAAC;IAED;;;;;;OAMG;IACH,SAAS,CAAC,IAA2D;QAInE,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;QACtD,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC3B,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,GAAG,CAAC,CAAC;QACrD,IAAI,QAAQ,EAAE,CAAC;YACb,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,YAAY,EAAE,QAAQ,CAAC,OAAO,EAAE,CAAC;QACzD,CAAC;QACD,MAAM,MAAM,GAAyB;YACnC,QAAQ,EAAE,GAAG;YACb,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,SAAS,EAAE,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,WAAW,EAAE;SAC9C,CAAC;QACF,IAAI,CAAC,QAAQ,CAAC,CAAC,GAAG,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;QAChC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC;IACpD,CAAC;IAED,iFAAiF;IACjF,UAAU,CAAC,SAAiB,EAAE,KAAa;QACzC,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,IAAI,CAAC,QAAQ,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC;IACpF,CAAC;IAED,oGAAoG;IACpG,WAAW,CAAC,SAAiB;QAC3B,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC3B,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,KAAK,SAAS,CAAC,CAAC;QAC1D,IAAI,IAAI,CAAC,MAAM,KAAK,GAAG,CAAC,MAAM;YAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IACtD,CAAC;IAED,IAAI;QACF,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC;IACxB,CAAC;CACF"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export type SingleFlightState = 'enroll-in-flight' | 'login-issued' | 'completed' | 'failed';
|
|
2
|
+
export interface SingleFlightRecord {
|
|
3
|
+
/** `${accountId}::${targetMachineId}` (canonical account id). */
|
|
4
|
+
key: string;
|
|
5
|
+
state: SingleFlightState;
|
|
6
|
+
/** The fronting machine that owns this enroll loop (provenance; this store is per-machine). */
|
|
7
|
+
frontingMachineId: string;
|
|
8
|
+
/** The mandate authorizing this enrollment. */
|
|
9
|
+
mandateId: string;
|
|
10
|
+
/** Opaque holder token (e.g. a process/run id) for dead-holder auto-heal. */
|
|
11
|
+
holder: string;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
/** While ACTIVE, the wall-clock (ms) past which the holder is presumed dead and the pair is reclaimable. */
|
|
14
|
+
ttlExpiresAt: number;
|
|
15
|
+
}
|
|
16
|
+
export interface SingleFlightDeps {
|
|
17
|
+
/** Absolute path to the ledger JSON file. */
|
|
18
|
+
filePath: string;
|
|
19
|
+
/** Injectable clock (ms) for tests. */
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
export declare function singleFlightKey(accountId: string, targetMachineId: string): string;
|
|
23
|
+
export interface ClaimInput {
|
|
24
|
+
accountId: string;
|
|
25
|
+
targetMachineId: string;
|
|
26
|
+
frontingMachineId: string;
|
|
27
|
+
mandateId: string;
|
|
28
|
+
holder: string;
|
|
29
|
+
/** How long the enroll-in-flight claim is valid before a dead-holder reclaim is allowed. */
|
|
30
|
+
ttlMs: number;
|
|
31
|
+
}
|
|
32
|
+
export declare class AccountFollowMeSingleFlight {
|
|
33
|
+
private readonly d;
|
|
34
|
+
constructor(deps: SingleFlightDeps);
|
|
35
|
+
private now;
|
|
36
|
+
private readAll;
|
|
37
|
+
private writeAll;
|
|
38
|
+
get(key: string): SingleFlightRecord | undefined;
|
|
39
|
+
/** A pair is LIVE (refuse re-claim, suppress scan re-offer) iff it is in an active state AND not past its TTL. */
|
|
40
|
+
isActive(key: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The single-flight gate. Atomically claims (account,target) → `enroll-in-flight`
|
|
43
|
+
* iff no LIVE record exists. Returns `{ claimed:false }` while another enroll is
|
|
44
|
+
* genuinely in flight (the duplicate-login guard). A dead-holder (TTL lapsed) or a
|
|
45
|
+
* terminal record (completed/failed/absent) is re-claimable.
|
|
46
|
+
*/
|
|
47
|
+
tryClaim(input: ClaimInput): {
|
|
48
|
+
claimed: boolean;
|
|
49
|
+
record: SingleFlightRecord;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Move a claimed pair to a new state. Refreshes the TTL for the still-active
|
|
53
|
+
* `login-issued` state (the operator now has a window to tap the link); terminal
|
|
54
|
+
* states clear the TTL window. Refuses to transition a key the caller does not
|
|
55
|
+
* hold (holder mismatch) so a stale actor can't stomp a live claim. No-op if absent.
|
|
56
|
+
*/
|
|
57
|
+
transition(key: string, to: SingleFlightState, holder: string, opts?: {
|
|
58
|
+
ttlMs?: number;
|
|
59
|
+
}): {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
reason?: string;
|
|
62
|
+
record?: SingleFlightRecord;
|
|
63
|
+
};
|
|
64
|
+
/** Drop a record entirely (e.g. on revocation of the pair's mandate). Idempotent. */
|
|
65
|
+
remove(key: string): void;
|
|
66
|
+
list(): SingleFlightRecord[];
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=AccountFollowMeSingleFlight.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccountFollowMeSingleFlight.d.ts","sourceRoot":"","sources":["../../src/coordination/AccountFollowMeSingleFlight.ts"],"names":[],"mappings":"AAiCA,MAAM,MAAM,iBAAiB,GACzB,kBAAkB,GAClB,cAAc,GACd,WAAW,GACX,QAAQ,CAAC;AAKb,MAAM,WAAW,kBAAkB;IACjC,iEAAiE;IACjE,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,iBAAiB,CAAC;IACzB,+FAA+F;IAC/F,iBAAiB,EAAE,MAAM,CAAC;IAC1B,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,4GAA4G;IAC5G,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,6CAA6C;IAC7C,QAAQ,EAAE,MAAM,CAAC;IACjB,uCAAuC;IACvC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,CAElF;AAED,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,KAAK,EAAE,MAAM,CAAC;CACf;AAED,qBAAa,2BAA2B;IACtC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAmB;gBACzB,IAAI,EAAE,gBAAgB;IAIlC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,OAAO;IAUf,OAAO,CAAC,QAAQ;IAKhB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIhD,kHAAkH;IAClH,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAM9B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,kBAAkB,CAAA;KAAE;IAsB7E;;;;;OAKG;IACH,UAAU,CACR,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GACxB;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,kBAAkB,CAAA;KAAE;IAoBhE,qFAAqF;IACrF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAMzB,IAAI,IAAI,kBAAkB,EAAE;CAG7B"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS5.2 (ws52-operator-tap-not-text Part B) — DURABLE single-flight ledger for the
|
|
3
|
+
* account-follow-me cross-machine enroll connector.
|
|
4
|
+
*
|
|
5
|
+
* The convergence pass found that the live "single-flight per (account,target)"
|
|
6
|
+
* guarantee was wired as `inFlight: () => new Set()` — a fresh empty set every
|
|
7
|
+
* call, i.e. a NO-OP. A re-delivery, a restart, or a double-tap each spawned a new
|
|
8
|
+
* device-code login. This store is the real, durable, restart-surviving guarantee.
|
|
9
|
+
*
|
|
10
|
+
* It is a small state machine keyed `${accountId}::${targetMachineId}` (R3.1: the
|
|
11
|
+
* CANONICAL pool account id, owned within THIS fronting machine's ledger — the
|
|
12
|
+
* store is per-machine, so the frontingMachineId is recorded for provenance and a
|
|
13
|
+
* different fronting context cannot suppress this one's offers):
|
|
14
|
+
*
|
|
15
|
+
* (absent) --tryClaim--> enroll-in-flight --markLoginIssued--> login-issued
|
|
16
|
+
* | |
|
|
17
|
+
* v v
|
|
18
|
+
* failed <----markFailed------------ completed
|
|
19
|
+
*
|
|
20
|
+
* `tryClaim` is the single-flight gate: it succeeds ONLY when there is no LIVE
|
|
21
|
+
* in-flight record (absent / completed / failed / a dead-holder whose TTL lapsed).
|
|
22
|
+
* While a pair is `enroll-in-flight` or `login-issued`, a second claim is REFUSED —
|
|
23
|
+
* so a re-delivery/restart/double-tap collapses to one login. A crash mid-enroll is
|
|
24
|
+
* self-healed: a claim past `ttlExpiresAt` reclaims the dead holder (PR-hand-lease
|
|
25
|
+
* pattern). `completed`/`failed` are terminal-but-re-armable: a genuinely fresh
|
|
26
|
+
* delivery can re-claim the pair (a new mandate, after the prior cycle closed).
|
|
27
|
+
*
|
|
28
|
+
* Pure + fs-backed (the DeliveredMandateStore pattern) so both sides of every
|
|
29
|
+
* transition are unit-testable without a server.
|
|
30
|
+
*/
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
/** The states in which a pair is LIVE — re-claim is refused and the scan must not re-offer. */
|
|
34
|
+
const ACTIVE_STATES = new Set(['enroll-in-flight', 'login-issued']);
|
|
35
|
+
export function singleFlightKey(accountId, targetMachineId) {
|
|
36
|
+
return `${accountId}::${targetMachineId}`;
|
|
37
|
+
}
|
|
38
|
+
export class AccountFollowMeSingleFlight {
|
|
39
|
+
d;
|
|
40
|
+
constructor(deps) {
|
|
41
|
+
this.d = deps;
|
|
42
|
+
}
|
|
43
|
+
now() {
|
|
44
|
+
return this.d.now ? this.d.now() : Date.now();
|
|
45
|
+
}
|
|
46
|
+
readAll() {
|
|
47
|
+
try {
|
|
48
|
+
const raw = JSON.parse(fs.readFileSync(this.d.filePath, 'utf8'));
|
|
49
|
+
return Array.isArray(raw) ? raw : [];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// @silent-fallback-ok — no ledger yet; an empty ledger means "nothing in flight" (safe).
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
writeAll(records) {
|
|
57
|
+
fs.mkdirSync(path.dirname(this.d.filePath), { recursive: true });
|
|
58
|
+
fs.writeFileSync(this.d.filePath, JSON.stringify(records, null, 2));
|
|
59
|
+
}
|
|
60
|
+
get(key) {
|
|
61
|
+
return this.readAll().find((r) => r.key === key);
|
|
62
|
+
}
|
|
63
|
+
/** A pair is LIVE (refuse re-claim, suppress scan re-offer) iff it is in an active state AND not past its TTL. */
|
|
64
|
+
isActive(key) {
|
|
65
|
+
const r = this.get(key);
|
|
66
|
+
if (!r || !ACTIVE_STATES.has(r.state))
|
|
67
|
+
return false;
|
|
68
|
+
return this.now() <= r.ttlExpiresAt; // a lapsed holder is NOT live (reclaimable)
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The single-flight gate. Atomically claims (account,target) → `enroll-in-flight`
|
|
72
|
+
* iff no LIVE record exists. Returns `{ claimed:false }` while another enroll is
|
|
73
|
+
* genuinely in flight (the duplicate-login guard). A dead-holder (TTL lapsed) or a
|
|
74
|
+
* terminal record (completed/failed/absent) is re-claimable.
|
|
75
|
+
*/
|
|
76
|
+
tryClaim(input) {
|
|
77
|
+
const key = singleFlightKey(input.accountId, input.targetMachineId);
|
|
78
|
+
const all = this.readAll();
|
|
79
|
+
const existing = all.find((r) => r.key === key);
|
|
80
|
+
const now = this.now();
|
|
81
|
+
const live = existing && ACTIVE_STATES.has(existing.state) && now <= existing.ttlExpiresAt;
|
|
82
|
+
if (live) {
|
|
83
|
+
return { claimed: false, record: existing };
|
|
84
|
+
}
|
|
85
|
+
const record = {
|
|
86
|
+
key,
|
|
87
|
+
state: 'enroll-in-flight',
|
|
88
|
+
frontingMachineId: input.frontingMachineId,
|
|
89
|
+
mandateId: input.mandateId,
|
|
90
|
+
holder: input.holder,
|
|
91
|
+
updatedAt: new Date(now).toISOString(),
|
|
92
|
+
ttlExpiresAt: now + Math.max(0, input.ttlMs),
|
|
93
|
+
};
|
|
94
|
+
this.writeAll([...all.filter((r) => r.key !== key), record]);
|
|
95
|
+
return { claimed: true, record };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Move a claimed pair to a new state. Refreshes the TTL for the still-active
|
|
99
|
+
* `login-issued` state (the operator now has a window to tap the link); terminal
|
|
100
|
+
* states clear the TTL window. Refuses to transition a key the caller does not
|
|
101
|
+
* hold (holder mismatch) so a stale actor can't stomp a live claim. No-op if absent.
|
|
102
|
+
*/
|
|
103
|
+
transition(key, to, holder, opts) {
|
|
104
|
+
const all = this.readAll();
|
|
105
|
+
const idx = all.findIndex((r) => r.key === key);
|
|
106
|
+
if (idx < 0)
|
|
107
|
+
return { ok: false, reason: 'absent' };
|
|
108
|
+
if (all[idx].holder !== holder)
|
|
109
|
+
return { ok: false, reason: 'holder-mismatch' };
|
|
110
|
+
const now = this.now();
|
|
111
|
+
const ttlExpiresAt = ACTIVE_STATES.has(to)
|
|
112
|
+
? now + Math.max(0, opts?.ttlMs ?? 0)
|
|
113
|
+
: 0;
|
|
114
|
+
const next = {
|
|
115
|
+
...all[idx],
|
|
116
|
+
state: to,
|
|
117
|
+
updatedAt: new Date(now).toISOString(),
|
|
118
|
+
ttlExpiresAt,
|
|
119
|
+
};
|
|
120
|
+
all[idx] = next;
|
|
121
|
+
this.writeAll(all);
|
|
122
|
+
return { ok: true, record: next };
|
|
123
|
+
}
|
|
124
|
+
/** Drop a record entirely (e.g. on revocation of the pair's mandate). Idempotent. */
|
|
125
|
+
remove(key) {
|
|
126
|
+
const all = this.readAll();
|
|
127
|
+
const next = all.filter((r) => r.key !== key);
|
|
128
|
+
if (next.length !== all.length)
|
|
129
|
+
this.writeAll(next);
|
|
130
|
+
}
|
|
131
|
+
list() {
|
|
132
|
+
return this.readAll();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=AccountFollowMeSingleFlight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AccountFollowMeSingleFlight.js","sourceRoot":"","sources":["../../src/coordination/AccountFollowMeSingleFlight.ts"],"names":[],"mappings":"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"}
|