instar 1.3.618 → 1.3.619
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/subscriptions.js +74 -0
- 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 +54 -3
- 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/side-effects/ws52-operator-tap-not-text.md +112 -0
|
@@ -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;
|
|
@@ -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":"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"}
|
|
@@ -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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"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS5.2 (ws52-operator-tap-not-text Part B) — the durable enroll CONSUMER's
|
|
3
|
+
* orchestration: drive ONE delivered account-follow-me mandate through enrollment,
|
|
4
|
+
* fail-closed, single-flight-guarded, with honest single-emission operator
|
|
5
|
+
* surfacing. This is the heart of the connector — the piece whose absence
|
|
6
|
+
* (`onMandateDelivered` had no callers) made the proof stall silently.
|
|
7
|
+
*
|
|
8
|
+
* It is dependency-INJECTED so the risky orchestration logic (the sequence, the
|
|
9
|
+
* fail-closed branches, the "exactly one operator message" surfacing, the
|
|
10
|
+
* no-silent-stall guarantee) is unit-testable without the real EnrollmentWizard,
|
|
11
|
+
* stores, or mesh. The real-dep wiring (the actual wizard / single-flight /
|
|
12
|
+
* outbox / mesh status verb) is mechanical and lands at integration.
|
|
13
|
+
*
|
|
14
|
+
* Invariants enforced here (from the convergence):
|
|
15
|
+
* - Point-of-use re-verify (bounds+expiry+revocation) BEFORE any enroll (#3).
|
|
16
|
+
* - Single-flight: a second drive for a LIVE pair is a no-op (#2).
|
|
17
|
+
* - Honest surfacing: EVERY terminal outcome (login-issued OR failed) produces
|
|
18
|
+
* exactly one operator message via the outbox (R3.3) — no silent stall.
|
|
19
|
+
* - Fail-closed: any thrown dependency ⇒ a recorded `failed` + one failure message,
|
|
20
|
+
* never an unrecorded crash.
|
|
21
|
+
*/
|
|
22
|
+
import type { AccountFollowMeSingleFlight } from './AccountFollowMeSingleFlight.js';
|
|
23
|
+
import type { AccountFollowMeOperatorOutbox } from './AccountFollowMeOperatorOutbox.js';
|
|
24
|
+
export interface LoginArtifact {
|
|
25
|
+
verificationUrl: string;
|
|
26
|
+
userCode: string;
|
|
27
|
+
ttlMs: number;
|
|
28
|
+
}
|
|
29
|
+
export interface DriveEnrollDeps {
|
|
30
|
+
singleFlight: AccountFollowMeSingleFlight;
|
|
31
|
+
outbox: AccountFollowMeOperatorOutbox;
|
|
32
|
+
/** Live revocation oracle (local flag + durable record); a throw ⇒ fail-closed. */
|
|
33
|
+
isRevoked: (mandateId: string) => boolean;
|
|
34
|
+
/** Drive the local re-mint; resolves a LoginArtifact (URL+code, never a token) or throws. */
|
|
35
|
+
startEnrollment: (args: {
|
|
36
|
+
accountId: string;
|
|
37
|
+
mandateId: string;
|
|
38
|
+
}) => Promise<LoginArtifact>;
|
|
39
|
+
/** Surface to the operator on the FRONTING machine (fail-closed if no verified topic). Returns delivered?. */
|
|
40
|
+
surfaceToOperator: (msg: {
|
|
41
|
+
kind: 'login-link' | 'failure';
|
|
42
|
+
accountId: string;
|
|
43
|
+
targetMachineId: string;
|
|
44
|
+
login?: LoginArtifact;
|
|
45
|
+
reason?: string;
|
|
46
|
+
}) => Promise<boolean>;
|
|
47
|
+
now: () => number;
|
|
48
|
+
frontingMachineId: string;
|
|
49
|
+
/** This drive's holder token (process/run id) for single-flight ownership. */
|
|
50
|
+
holder: string;
|
|
51
|
+
/** Single-flight claim TTL (dead-holder reclaim window). */
|
|
52
|
+
claimTtlMs: number;
|
|
53
|
+
/** Login-link visibility window written onto the login-issued ledger state. */
|
|
54
|
+
loginTtlMs: number;
|
|
55
|
+
}
|
|
56
|
+
export interface DriveEnrollInput {
|
|
57
|
+
mandateId: string;
|
|
58
|
+
expiresAt: string;
|
|
59
|
+
bounds: {
|
|
60
|
+
accountId: string;
|
|
61
|
+
targetMachineId: string;
|
|
62
|
+
};
|
|
63
|
+
requested: {
|
|
64
|
+
accountId: string;
|
|
65
|
+
targetMachineId: string;
|
|
66
|
+
};
|
|
67
|
+
/** A stable id for THIS delivery event (drives outbox idempotency). */
|
|
68
|
+
eventId: string;
|
|
69
|
+
}
|
|
70
|
+
export type DriveEnrollOutcome = {
|
|
71
|
+
ok: true;
|
|
72
|
+
state: 'login-issued';
|
|
73
|
+
login: LoginArtifact;
|
|
74
|
+
} | {
|
|
75
|
+
ok: false;
|
|
76
|
+
state: 'denied' | 'in-flight' | 'failed';
|
|
77
|
+
reason: string;
|
|
78
|
+
};
|
|
79
|
+
export declare function driveFollowMeEnrollment(deps: DriveEnrollDeps, input: DriveEnrollInput): Promise<DriveEnrollOutcome>;
|
|
80
|
+
//# sourceMappingURL=driveFollowMeEnrollment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driveFollowMeEnrollment.d.ts","sourceRoot":"","sources":["../../src/coordination/driveFollowMeEnrollment.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,kCAAkC,CAAC;AACpF,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,oCAAoC,CAAC;AAIxF,MAAM,WAAW,aAAa;IAC5B,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,YAAY,EAAE,2BAA2B,CAAC;IAC1C,MAAM,EAAE,6BAA6B,CAAC;IACtC,mFAAmF;IACnF,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC;IAC1C,6FAA6F;IAC7F,eAAe,EAAE,CAAC,IAAI,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;IAC5F,8GAA8G;IAC9G,iBAAiB,EAAE,CAAC,GAAG,EAAE;QACvB,IAAI,EAAE,YAAY,GAAG,SAAS,CAAC;QAC/B,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,MAAM,CAAC;QACxB,KAAK,CAAC,EAAE,aAAa,CAAC;QACtB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACvB,GAAG,EAAE,MAAM,MAAM,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,8EAA8E;IAC9E,MAAM,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,UAAU,EAAE,MAAM,CAAC;IACnB,+EAA+E;IAC/E,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IACvD,SAAS,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1D,uEAAuE;IACvE,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,cAAc,CAAC;IAAC,KAAK,EAAE,aAAa,CAAA;CAAE,GACzD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,QAAQ,GAAG,WAAW,GAAG,QAAQ,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5E,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,eAAe,EACrB,KAAK,EAAE,gBAAgB,GACtB,OAAO,CAAC,kBAAkB,CAAC,CA+D7B"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { checkDeliveredMandateUsableForEnroll } from './enrollPointOfUseCheck.js';
|
|
2
|
+
import { singleFlightKey } from './AccountFollowMeSingleFlight.js';
|
|
3
|
+
export async function driveFollowMeEnrollment(deps, input) {
|
|
4
|
+
const key = singleFlightKey(input.requested.accountId, input.requested.targetMachineId);
|
|
5
|
+
// 1. Point-of-use re-verify (#3) — bounds + expiry + LIVE revocation, fail-closed.
|
|
6
|
+
const check = checkDeliveredMandateUsableForEnroll({
|
|
7
|
+
mandateId: input.mandateId,
|
|
8
|
+
expiresAt: input.expiresAt,
|
|
9
|
+
bounds: input.bounds,
|
|
10
|
+
requested: input.requested,
|
|
11
|
+
now: deps.now(),
|
|
12
|
+
isRevoked: deps.isRevoked,
|
|
13
|
+
});
|
|
14
|
+
if (!check.ok) {
|
|
15
|
+
// A denied mandate is NOT an in-flight failure — it never enrolled. No operator
|
|
16
|
+
// spam (a revoked/expired mandate the operator already knows about); just deny.
|
|
17
|
+
return { ok: false, state: 'denied', reason: check.reason };
|
|
18
|
+
}
|
|
19
|
+
// 2. Single-flight (#2) — refuse a duplicate drive for a LIVE pair.
|
|
20
|
+
const claim = deps.singleFlight.tryClaim({
|
|
21
|
+
accountId: input.requested.accountId,
|
|
22
|
+
targetMachineId: input.requested.targetMachineId,
|
|
23
|
+
frontingMachineId: deps.frontingMachineId,
|
|
24
|
+
mandateId: input.mandateId,
|
|
25
|
+
holder: deps.holder,
|
|
26
|
+
ttlMs: deps.claimTtlMs,
|
|
27
|
+
});
|
|
28
|
+
if (!claim.claimed) {
|
|
29
|
+
return { ok: false, state: 'in-flight', reason: 'enrollment already in flight for this pair' };
|
|
30
|
+
}
|
|
31
|
+
// 3. Drive the re-mint. ANY throw ⇒ recorded failed + ONE honest failure message
|
|
32
|
+
// (the no-silent-stall guarantee — the exact bug this connector fixes).
|
|
33
|
+
let login;
|
|
34
|
+
try {
|
|
35
|
+
login = await deps.startEnrollment({ accountId: input.requested.accountId, mandateId: input.mandateId });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
deps.singleFlight.transition(key, 'failed', deps.holder);
|
|
39
|
+
const reason = err instanceof Error ? err.message : 'enrollment drive failed';
|
|
40
|
+
const emit = deps.outbox.claimEmit({ ledgerKey: key, state: 'failed', eventId: input.eventId });
|
|
41
|
+
if (emit.emit) {
|
|
42
|
+
await deps.surfaceToOperator({
|
|
43
|
+
kind: 'failure',
|
|
44
|
+
accountId: input.requested.accountId,
|
|
45
|
+
targetMachineId: input.requested.targetMachineId,
|
|
46
|
+
reason,
|
|
47
|
+
}).catch(() => { });
|
|
48
|
+
}
|
|
49
|
+
return { ok: false, state: 'failed', reason };
|
|
50
|
+
}
|
|
51
|
+
// 4. login-issued + ONE login-link message (outbox-deduped across redelivery/restart).
|
|
52
|
+
deps.singleFlight.transition(key, 'login-issued', deps.holder, { ttlMs: deps.loginTtlMs });
|
|
53
|
+
const emit = deps.outbox.claimEmit({ ledgerKey: key, state: 'login-issued', eventId: input.eventId });
|
|
54
|
+
if (emit.emit) {
|
|
55
|
+
await deps.surfaceToOperator({
|
|
56
|
+
kind: 'login-link',
|
|
57
|
+
accountId: input.requested.accountId,
|
|
58
|
+
targetMachineId: input.requested.targetMachineId,
|
|
59
|
+
login,
|
|
60
|
+
}).catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, state: 'login-issued', login };
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=driveFollowMeEnrollment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driveFollowMeEnrollment.js","sourceRoot":"","sources":["../../src/coordination/driveFollowMeEnrollment.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS5.2 (ws52-operator-tap-not-text Part B, R3.4) — point-of-use re-verification for
|
|
3
|
+
* the cross-machine enroll connector.
|
|
4
|
+
*
|
|
5
|
+
* Convergence critical #3: the delivered-mandate enroll path re-verified ONLY the
|
|
6
|
+
* R4a issuance signature + bounds (`verifyDeliveredMandate`) — it checked NEITHER
|
|
7
|
+
* expiry NOR revocation, and revoke never purged the delivered store. So the
|
|
8
|
+
* auto-enroll connector would honor a REVOKED or EXPIRED cross-machine mandate and
|
|
9
|
+
* mint a fresh login the operator had killed. This pure predicate is the temporal +
|
|
10
|
+
* revocation gate the convergence requires, run BEFORE every state transition that
|
|
11
|
+
* could start or continue an enrollment ("never trust the stored flag").
|
|
12
|
+
*
|
|
13
|
+
* It is deliberately decoupled from the concrete mandate/store types: the caller
|
|
14
|
+
* supplies the minimal facts (the mandate's id, expiry, and account-follow-me
|
|
15
|
+
* bounds; the requested pair; the clock; and a LIVE revocation oracle). Fail-closed
|
|
16
|
+
* on every uncertainty — an unknown/throwing revocation oracle is treated as
|
|
17
|
+
* "revoked" (the safe direction), never "allow".
|
|
18
|
+
*/
|
|
19
|
+
export type EnrollDenyReason = 'expired' | 'revoked' | 'bounds-mismatch' | 'revocation-unknown' | 'bad-expiry';
|
|
20
|
+
export interface EnrollPointOfUseInput {
|
|
21
|
+
mandateId: string;
|
|
22
|
+
/** The mandate's ISO expiry (R4a-signed). */
|
|
23
|
+
expiresAt: string;
|
|
24
|
+
/** The account-follow-me bounds the mandate was signed for. */
|
|
25
|
+
bounds: {
|
|
26
|
+
accountId: string;
|
|
27
|
+
targetMachineId: string;
|
|
28
|
+
};
|
|
29
|
+
/** What this enrollment is about to do — MUST equal the signed bounds. */
|
|
30
|
+
requested: {
|
|
31
|
+
accountId: string;
|
|
32
|
+
targetMachineId: string;
|
|
33
|
+
};
|
|
34
|
+
/** Wall-clock ms (injectable). */
|
|
35
|
+
now: number;
|
|
36
|
+
/**
|
|
37
|
+
* LIVE revocation oracle: true ⇒ revoked. It MUST reflect both the local
|
|
38
|
+
* `mandate.revoked` flag and the durable revocation record. A throw is treated
|
|
39
|
+
* as revocation-unknown ⇒ DENY (fail-closed) — never silently allowed.
|
|
40
|
+
*/
|
|
41
|
+
isRevoked: (mandateId: string) => boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface EnrollPointOfUseResult {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
reason: EnrollDenyReason | 'ok';
|
|
46
|
+
}
|
|
47
|
+
export declare function checkDeliveredMandateUsableForEnroll(input: EnrollPointOfUseInput): EnrollPointOfUseResult;
|
|
48
|
+
//# sourceMappingURL=enrollPointOfUseCheck.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"enrollPointOfUseCheck.d.ts","sourceRoot":"","sources":["../../src/coordination/enrollPointOfUseCheck.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,gBAAgB,GACxB,SAAS,GACT,SAAS,GACT,iBAAiB,GACjB,oBAAoB,GACpB,YAAY,CAAC;AAEjB,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,6CAA6C;IAC7C,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,MAAM,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IACvD,0EAA0E;IAC1E,SAAS,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1D,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC;CAC3C;AAED,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACjC;AAED,wBAAgB,oCAAoC,CAClD,KAAK,EAAE,qBAAqB,GAC3B,sBAAsB,CA0BxB"}
|