instar 1.2.34 → 1.2.35
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/dist/tunnel/CloudflareNamedProvider.d.ts +51 -0
- package/dist/tunnel/CloudflareNamedProvider.d.ts.map +1 -0
- package/dist/tunnel/CloudflareNamedProvider.js +258 -0
- package/dist/tunnel/CloudflareNamedProvider.js.map +1 -0
- package/dist/tunnel/CloudflareQuickProvider.d.ts +48 -0
- package/dist/tunnel/CloudflareQuickProvider.d.ts.map +1 -0
- package/dist/tunnel/CloudflareQuickProvider.js +201 -0
- package/dist/tunnel/CloudflareQuickProvider.js.map +1 -0
- package/dist/tunnel/TunnelLifecycle.d.ts +190 -0
- package/dist/tunnel/TunnelLifecycle.d.ts.map +1 -0
- package/dist/tunnel/TunnelLifecycle.js +222 -0
- package/dist/tunnel/TunnelLifecycle.js.map +1 -0
- package/dist/tunnel/TunnelNotifier.d.ts +110 -0
- package/dist/tunnel/TunnelNotifier.d.ts.map +1 -0
- package/dist/tunnel/TunnelNotifier.js +268 -0
- package/dist/tunnel/TunnelNotifier.js.map +1 -0
- package/dist/tunnel/TunnelProvider.d.ts +80 -0
- package/dist/tunnel/TunnelProvider.d.ts.map +1 -0
- package/dist/tunnel/TunnelProvider.js +28 -0
- package/dist/tunnel/TunnelProvider.js.map +1 -0
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.2.35.md +96 -0
- package/upgrades/side-effects/tunnel-failure-resilience.md +118 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelLifecycle — single-owner state machine for the tunnel layer.
|
|
3
|
+
*
|
|
4
|
+
* Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 2.
|
|
5
|
+
*
|
|
6
|
+
* This module owns the detect → attempt → fall-back → notify → self-heal
|
|
7
|
+
* lifecycle. The existing server.ts startup-retry ladder, background-
|
|
8
|
+
* retry ladder, and Lifeline failure message are retired in favor of
|
|
9
|
+
* routing all retry through here.
|
|
10
|
+
*
|
|
11
|
+
* Design properties:
|
|
12
|
+
* - Single-writer transitions: every state change goes through
|
|
13
|
+
* `transition(expectedFrom, to)`, a compare-and-swap that REJECTS
|
|
14
|
+
* any transition whose expectedFrom doesn't match the current
|
|
15
|
+
* state. This prevents the error+exit double-handler race that the
|
|
16
|
+
* concurrency reviewer surfaced.
|
|
17
|
+
* - Monotonic epoch: each guarded transition carries a `transition.id`
|
|
18
|
+
* for downstream notification dedup. Notifications are emitted ONLY
|
|
19
|
+
* from inside the guarded mutation, tagged with the epoch.
|
|
20
|
+
* - Episode model: each contiguous failure→recovery cycle is an
|
|
21
|
+
* `episode` with its own id and one-time consent nonce. All consent
|
|
22
|
+
* state binds to (episodeId, provider, ownerId, chatId, messageId,
|
|
23
|
+
* issuedAt).
|
|
24
|
+
*
|
|
25
|
+
* This module is provider-agnostic and notification-channel-agnostic.
|
|
26
|
+
* Providers are injected as a pool; the notifier is injected as a sink.
|
|
27
|
+
* Both responsibilities live OUTSIDE this module per the spec's
|
|
28
|
+
* separation of concerns.
|
|
29
|
+
*/
|
|
30
|
+
import { EventEmitter } from 'node:events';
|
|
31
|
+
import type { ProviderFailureReason, ProviderName } from './TunnelProvider.js';
|
|
32
|
+
export type TunnelLifecycleState =
|
|
33
|
+
/** No tunnel running and not currently trying. */
|
|
34
|
+
'idle'
|
|
35
|
+
/** A start attempt is in flight on some provider. */
|
|
36
|
+
| 'starting'
|
|
37
|
+
/** A provider is up; URL is being served. */
|
|
38
|
+
| 'active'
|
|
39
|
+
/** A Tier-1 provider failed and we are backing off / trying the next one. */
|
|
40
|
+
| 'retrying'
|
|
41
|
+
/** All Tier-1 providers exhausted; awaiting owner consent for a Tier-2 relay. */
|
|
42
|
+
| 'awaiting-consent'
|
|
43
|
+
/** A Tier-2 relay provider is up; URL being served via the relay. */
|
|
44
|
+
| 'relay-active'
|
|
45
|
+
/** Cloudflare recovery probe verifying Tier-1 is back; switch-back imminent. */
|
|
46
|
+
| 'self-healing'
|
|
47
|
+
/** All providers exhausted (or consent declined); background self-heal is the only retry mechanism. */
|
|
48
|
+
| 'exhausted';
|
|
49
|
+
export interface Episode {
|
|
50
|
+
/** Stable per-failure-cycle id. Resets each time the manager enters `starting` from `idle`/`active`. */
|
|
51
|
+
episodeId: string;
|
|
52
|
+
/** When this episode began (ISO timestamp). */
|
|
53
|
+
startedAt: string;
|
|
54
|
+
/** Number of Tier-1 providers attempted in this episode. */
|
|
55
|
+
tier1Attempts: number;
|
|
56
|
+
/** Last classified failure reason (the most recent provider failure). */
|
|
57
|
+
lastFailureReason: ProviderFailureReason | null;
|
|
58
|
+
/** Which provider names have been attempted in this episode (in order). */
|
|
59
|
+
attemptedProviders: ProviderName[];
|
|
60
|
+
}
|
|
61
|
+
export interface ConsentRecord {
|
|
62
|
+
/** Tied to the episode that issued the prompt. */
|
|
63
|
+
episodeId: string;
|
|
64
|
+
/** Which Tier-2 provider the consent authorizes. Single-use, per-provider. */
|
|
65
|
+
provider: 'localtunnel' | 'bore';
|
|
66
|
+
/** Owner principal id (Telegram user id). Non-owner clicks are rejected. */
|
|
67
|
+
ownerId: string;
|
|
68
|
+
/** Telegram chat the consent was issued in (DM chat id). */
|
|
69
|
+
chatId: string;
|
|
70
|
+
/** Telegram message id of the inline-button prompt. */
|
|
71
|
+
messageId: string;
|
|
72
|
+
/** 128-bit CSPRNG nonce carried in callback_data. Atomic compare-and-delete on use. */
|
|
73
|
+
nonce: string;
|
|
74
|
+
/** Issued-at (ms epoch). Combined with consentTimeoutMs to expire. */
|
|
75
|
+
issuedAt: number;
|
|
76
|
+
}
|
|
77
|
+
export interface ConsentCooldownState {
|
|
78
|
+
/** Count of consecutive declines/timeouts feeding the exponential backoff. */
|
|
79
|
+
consecutiveRefusals: number;
|
|
80
|
+
/** When the cooldown was last extended (ms epoch). */
|
|
81
|
+
lastExtendedAt: number;
|
|
82
|
+
/** Until when the cooldown is active (ms epoch). 0 = no cooldown. */
|
|
83
|
+
activeUntil: number;
|
|
84
|
+
}
|
|
85
|
+
export interface PersistedTunnelState {
|
|
86
|
+
/** Schema version for safe future migrations. */
|
|
87
|
+
version: 1;
|
|
88
|
+
/** Last observed state. Used by the boot-recovery path. */
|
|
89
|
+
lastState: TunnelLifecycleState;
|
|
90
|
+
/** Last active URL (if any) — for diagnostic only; never trusted as live. */
|
|
91
|
+
lastUrl: string | null;
|
|
92
|
+
/** Name of the provider that was active when state was persisted. */
|
|
93
|
+
activeProvider: ProviderName | null;
|
|
94
|
+
/**
|
|
95
|
+
* Set to true the moment a Tier-2 relay-active state is entered. Cleared
|
|
96
|
+
* only after `authToken`/PIN rotation succeeds. If the agent crashes
|
|
97
|
+
* mid-relay-episode, this flag tells the boot-recovery path "rotate
|
|
98
|
+
* BEFORE accepting any API traffic on the new boot" — per spec Part 6.
|
|
99
|
+
*/
|
|
100
|
+
rotationPending: boolean;
|
|
101
|
+
/** Cross-episode consent cooldown counter. */
|
|
102
|
+
consentCooldown: ConsentCooldownState;
|
|
103
|
+
/** Current episode, if any. */
|
|
104
|
+
episode: Episode | null;
|
|
105
|
+
/** Saved-at timestamp (ISO). */
|
|
106
|
+
savedAt: string;
|
|
107
|
+
}
|
|
108
|
+
export interface TransitionEvent {
|
|
109
|
+
/** Monotonic per-manager-instance epoch — never reset. Used by notifiers for dedup. */
|
|
110
|
+
epoch: number;
|
|
111
|
+
/** State BEFORE this transition. */
|
|
112
|
+
from: TunnelLifecycleState;
|
|
113
|
+
/** State AFTER this transition. */
|
|
114
|
+
to: TunnelLifecycleState;
|
|
115
|
+
/** Episode in effect at the time of the transition. */
|
|
116
|
+
episode: Episode | null;
|
|
117
|
+
/** Last classified failure (when relevant). */
|
|
118
|
+
lastFailureReason: ProviderFailureReason | null;
|
|
119
|
+
/** When (ms epoch). */
|
|
120
|
+
at: number;
|
|
121
|
+
}
|
|
122
|
+
export type TunnelLifecycleEvents = {
|
|
123
|
+
/** Fires on every successful guarded transition. */
|
|
124
|
+
transition: (e: TransitionEvent) => void;
|
|
125
|
+
};
|
|
126
|
+
/** Classify a provider failure based on the Error's `message`. */
|
|
127
|
+
export declare function classifyFailure(err: unknown): ProviderFailureReason;
|
|
128
|
+
/** Generate a fresh 128-bit CSPRNG nonce as hex (32 chars). */
|
|
129
|
+
export declare function generateNonce(): string;
|
|
130
|
+
/** Generate a fresh episode id (16-byte hex, no PII). */
|
|
131
|
+
export declare function generateEpisodeId(): string;
|
|
132
|
+
export declare function isValidTransition(from: TunnelLifecycleState, to: TunnelLifecycleState): boolean;
|
|
133
|
+
export declare class TunnelLifecycle extends EventEmitter {
|
|
134
|
+
private _state;
|
|
135
|
+
private _epoch;
|
|
136
|
+
private _episode;
|
|
137
|
+
private _lastFailureReason;
|
|
138
|
+
private _activeProvider;
|
|
139
|
+
private _consentCooldown;
|
|
140
|
+
private _rotationPending;
|
|
141
|
+
get state(): TunnelLifecycleState;
|
|
142
|
+
get epoch(): number;
|
|
143
|
+
get episode(): Episode | null;
|
|
144
|
+
get activeProvider(): ProviderName | null;
|
|
145
|
+
get rotationPending(): boolean;
|
|
146
|
+
get consentCooldown(): ConsentCooldownState;
|
|
147
|
+
get lastFailureReason(): ProviderFailureReason | null;
|
|
148
|
+
/**
|
|
149
|
+
* Compare-and-swap transition. Returns true if the transition was
|
|
150
|
+
* accepted; false if the current state didn't match `expectedFrom`
|
|
151
|
+
* (caller MUST treat false as a race — the lifecycle moved
|
|
152
|
+
* underneath them — and re-read state).
|
|
153
|
+
*
|
|
154
|
+
* `to` must be reachable from `expectedFrom` per VALID_TRANSITIONS;
|
|
155
|
+
* an invalid pair throws (programming error, not a runtime race).
|
|
156
|
+
*/
|
|
157
|
+
transition(expectedFrom: TunnelLifecycleState, to: TunnelLifecycleState, ctx?: {
|
|
158
|
+
episode?: Episode | null;
|
|
159
|
+
activeProvider?: ProviderName | null;
|
|
160
|
+
lastFailureReason?: ProviderFailureReason | null;
|
|
161
|
+
rotationPending?: boolean;
|
|
162
|
+
}): boolean;
|
|
163
|
+
/** Begin a new failure-recovery episode. Called when entering `starting` from `idle` or `active`. */
|
|
164
|
+
startEpisode(): Episode;
|
|
165
|
+
/** Close out the current episode (called when returning to `active` or `idle`). */
|
|
166
|
+
endEpisode(): void;
|
|
167
|
+
/** Record a Tier-1 provider failure attempt against the current episode. */
|
|
168
|
+
recordAttempt(provider: ProviderName, reason: ProviderFailureReason): void;
|
|
169
|
+
/** Apply an exponential consent cooldown after a decline / timeout. */
|
|
170
|
+
recordConsentRefusal(): ConsentCooldownState;
|
|
171
|
+
/** Clear cooldown — called on explicit owner opt-in or fresh post-cooldown episode. */
|
|
172
|
+
clearConsentCooldown(): void;
|
|
173
|
+
/** True if the consent prompt is currently suppressed by the cooldown. */
|
|
174
|
+
isConsentSuppressed(now?: number): boolean;
|
|
175
|
+
/** Snapshot current state for persistence to tunnel.json. */
|
|
176
|
+
snapshot(): PersistedTunnelState;
|
|
177
|
+
/**
|
|
178
|
+
* Restore from a persisted snapshot. Called at boot ONLY for the
|
|
179
|
+
* cooldown counter + rotationPending flag — the state itself is NOT
|
|
180
|
+
* resumed (every boot starts at `idle` and re-enters `starting` from
|
|
181
|
+
* scratch). The rotationPending flag triggers a rotation-before-
|
|
182
|
+
* traffic in the boot-recovery path; rotation clears the flag.
|
|
183
|
+
*/
|
|
184
|
+
restoreFrom(snapshot: PersistedTunnelState): void;
|
|
185
|
+
/** Set the rotation-pending flag (entered on `relay-active`; cleared after rotation). */
|
|
186
|
+
setRotationPending(value: boolean): void;
|
|
187
|
+
/** Used by callers (manager) that need to validate a consent-reply against the active state. */
|
|
188
|
+
isAwaitingConsent(): boolean;
|
|
189
|
+
}
|
|
190
|
+
//# sourceMappingURL=TunnelLifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelLifecycle.d.ts","sourceRoot":"","sources":["../../src/tunnel/TunnelLifecycle.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelLifecycle — single-owner state machine for the tunnel layer.
|
|
3
|
+
*
|
|
4
|
+
* Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 2.
|
|
5
|
+
*
|
|
6
|
+
* This module owns the detect → attempt → fall-back → notify → self-heal
|
|
7
|
+
* lifecycle. The existing server.ts startup-retry ladder, background-
|
|
8
|
+
* retry ladder, and Lifeline failure message are retired in favor of
|
|
9
|
+
* routing all retry through here.
|
|
10
|
+
*
|
|
11
|
+
* Design properties:
|
|
12
|
+
* - Single-writer transitions: every state change goes through
|
|
13
|
+
* `transition(expectedFrom, to)`, a compare-and-swap that REJECTS
|
|
14
|
+
* any transition whose expectedFrom doesn't match the current
|
|
15
|
+
* state. This prevents the error+exit double-handler race that the
|
|
16
|
+
* concurrency reviewer surfaced.
|
|
17
|
+
* - Monotonic epoch: each guarded transition carries a `transition.id`
|
|
18
|
+
* for downstream notification dedup. Notifications are emitted ONLY
|
|
19
|
+
* from inside the guarded mutation, tagged with the epoch.
|
|
20
|
+
* - Episode model: each contiguous failure→recovery cycle is an
|
|
21
|
+
* `episode` with its own id and one-time consent nonce. All consent
|
|
22
|
+
* state binds to (episodeId, provider, ownerId, chatId, messageId,
|
|
23
|
+
* issuedAt).
|
|
24
|
+
*
|
|
25
|
+
* This module is provider-agnostic and notification-channel-agnostic.
|
|
26
|
+
* Providers are injected as a pool; the notifier is injected as a sink.
|
|
27
|
+
* Both responsibilities live OUTSIDE this module per the spec's
|
|
28
|
+
* separation of concerns.
|
|
29
|
+
*/
|
|
30
|
+
import crypto from 'node:crypto';
|
|
31
|
+
import { EventEmitter } from 'node:events';
|
|
32
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
33
|
+
/** Classify a provider failure based on the Error's `message`. */
|
|
34
|
+
export function classifyFailure(err) {
|
|
35
|
+
const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase();
|
|
36
|
+
if (msg.startsWith('rate-limited') || msg.includes('rate limit') || msg.includes('429') || msg.includes('1015')) {
|
|
37
|
+
return 'rate-limited';
|
|
38
|
+
}
|
|
39
|
+
if (msg.startsWith('binary-missing') || msg.includes('not installed') || msg.includes('enoent')) {
|
|
40
|
+
return 'binary-missing';
|
|
41
|
+
}
|
|
42
|
+
if (msg.startsWith('network') || msg.includes('dns') || msg.includes('econnrefused') || msg.includes('eai_again')) {
|
|
43
|
+
return 'network';
|
|
44
|
+
}
|
|
45
|
+
if (msg.startsWith('timeout') || msg.includes('timed out')) {
|
|
46
|
+
return 'timeout';
|
|
47
|
+
}
|
|
48
|
+
if (msg.startsWith('reachability-failed')) {
|
|
49
|
+
return 'reachability-failed';
|
|
50
|
+
}
|
|
51
|
+
if (msg.includes('process-exit') || msg.includes('exited')) {
|
|
52
|
+
return 'process-exit';
|
|
53
|
+
}
|
|
54
|
+
return 'unknown';
|
|
55
|
+
}
|
|
56
|
+
/** Generate a fresh 128-bit CSPRNG nonce as hex (32 chars). */
|
|
57
|
+
export function generateNonce() {
|
|
58
|
+
return crypto.randomBytes(16).toString('hex');
|
|
59
|
+
}
|
|
60
|
+
/** Generate a fresh episode id (16-byte hex, no PII). */
|
|
61
|
+
export function generateEpisodeId() {
|
|
62
|
+
return crypto.randomBytes(8).toString('hex');
|
|
63
|
+
}
|
|
64
|
+
// ── Single-writer state machine ─────────────────────────────────────
|
|
65
|
+
/** Valid transitions per the spec's state machine. */
|
|
66
|
+
const VALID_TRANSITIONS = new Map([
|
|
67
|
+
['idle', new Set(['starting'])],
|
|
68
|
+
['starting', new Set(['active', 'retrying', 'awaiting-consent', 'exhausted', 'idle'])],
|
|
69
|
+
['active', new Set(['retrying', 'idle'])],
|
|
70
|
+
['retrying', new Set(['active', 'starting', 'awaiting-consent', 'exhausted', 'idle'])],
|
|
71
|
+
['awaiting-consent', new Set(['relay-active', 'starting', 'active', 'exhausted', 'idle'])],
|
|
72
|
+
['relay-active', new Set(['self-healing', 'idle', 'exhausted'])],
|
|
73
|
+
['self-healing', new Set(['active', 'relay-active', 'exhausted', 'idle'])],
|
|
74
|
+
['exhausted', new Set(['self-healing', 'starting', 'idle'])],
|
|
75
|
+
]);
|
|
76
|
+
export function isValidTransition(from, to) {
|
|
77
|
+
if (from === to)
|
|
78
|
+
return false;
|
|
79
|
+
const allowed = VALID_TRANSITIONS.get(from);
|
|
80
|
+
return !!allowed && allowed.has(to);
|
|
81
|
+
}
|
|
82
|
+
export class TunnelLifecycle extends EventEmitter {
|
|
83
|
+
_state = 'idle';
|
|
84
|
+
_epoch = 0;
|
|
85
|
+
_episode = null;
|
|
86
|
+
_lastFailureReason = null;
|
|
87
|
+
_activeProvider = null;
|
|
88
|
+
_consentCooldown = {
|
|
89
|
+
consecutiveRefusals: 0,
|
|
90
|
+
lastExtendedAt: 0,
|
|
91
|
+
activeUntil: 0,
|
|
92
|
+
};
|
|
93
|
+
_rotationPending = false;
|
|
94
|
+
get state() { return this._state; }
|
|
95
|
+
get epoch() { return this._epoch; }
|
|
96
|
+
get episode() { return this._episode; }
|
|
97
|
+
get activeProvider() { return this._activeProvider; }
|
|
98
|
+
get rotationPending() { return this._rotationPending; }
|
|
99
|
+
get consentCooldown() { return { ...this._consentCooldown }; }
|
|
100
|
+
get lastFailureReason() { return this._lastFailureReason; }
|
|
101
|
+
/**
|
|
102
|
+
* Compare-and-swap transition. Returns true if the transition was
|
|
103
|
+
* accepted; false if the current state didn't match `expectedFrom`
|
|
104
|
+
* (caller MUST treat false as a race — the lifecycle moved
|
|
105
|
+
* underneath them — and re-read state).
|
|
106
|
+
*
|
|
107
|
+
* `to` must be reachable from `expectedFrom` per VALID_TRANSITIONS;
|
|
108
|
+
* an invalid pair throws (programming error, not a runtime race).
|
|
109
|
+
*/
|
|
110
|
+
transition(expectedFrom, to, ctx) {
|
|
111
|
+
if (this._state !== expectedFrom) {
|
|
112
|
+
return false; // CAS lost
|
|
113
|
+
}
|
|
114
|
+
if (!isValidTransition(expectedFrom, to)) {
|
|
115
|
+
throw new Error(`invalid transition ${expectedFrom} → ${to}`);
|
|
116
|
+
}
|
|
117
|
+
// Apply mutation atomically (Node is single-threaded; this synchronous
|
|
118
|
+
// block IS the critical section).
|
|
119
|
+
this._state = to;
|
|
120
|
+
this._epoch += 1;
|
|
121
|
+
if (ctx?.episode !== undefined)
|
|
122
|
+
this._episode = ctx.episode;
|
|
123
|
+
if (ctx?.activeProvider !== undefined)
|
|
124
|
+
this._activeProvider = ctx.activeProvider;
|
|
125
|
+
if (ctx?.lastFailureReason !== undefined)
|
|
126
|
+
this._lastFailureReason = ctx.lastFailureReason;
|
|
127
|
+
if (ctx?.rotationPending !== undefined)
|
|
128
|
+
this._rotationPending = ctx.rotationPending;
|
|
129
|
+
const event = {
|
|
130
|
+
epoch: this._epoch,
|
|
131
|
+
from: expectedFrom,
|
|
132
|
+
to,
|
|
133
|
+
episode: this._episode,
|
|
134
|
+
lastFailureReason: this._lastFailureReason,
|
|
135
|
+
at: Date.now(),
|
|
136
|
+
};
|
|
137
|
+
this.emit('transition', event);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
/** Begin a new failure-recovery episode. Called when entering `starting` from `idle` or `active`. */
|
|
141
|
+
startEpisode() {
|
|
142
|
+
const ep = {
|
|
143
|
+
episodeId: generateEpisodeId(),
|
|
144
|
+
startedAt: new Date().toISOString(),
|
|
145
|
+
tier1Attempts: 0,
|
|
146
|
+
lastFailureReason: null,
|
|
147
|
+
attemptedProviders: [],
|
|
148
|
+
};
|
|
149
|
+
this._episode = ep;
|
|
150
|
+
return ep;
|
|
151
|
+
}
|
|
152
|
+
/** Close out the current episode (called when returning to `active` or `idle`). */
|
|
153
|
+
endEpisode() {
|
|
154
|
+
this._episode = null;
|
|
155
|
+
}
|
|
156
|
+
/** Record a Tier-1 provider failure attempt against the current episode. */
|
|
157
|
+
recordAttempt(provider, reason) {
|
|
158
|
+
if (!this._episode)
|
|
159
|
+
return;
|
|
160
|
+
this._episode.tier1Attempts += 1;
|
|
161
|
+
this._episode.lastFailureReason = reason;
|
|
162
|
+
this._episode.attemptedProviders.push(provider);
|
|
163
|
+
this._lastFailureReason = reason;
|
|
164
|
+
}
|
|
165
|
+
/** Apply an exponential consent cooldown after a decline / timeout. */
|
|
166
|
+
recordConsentRefusal() {
|
|
167
|
+
this._consentCooldown.consecutiveRefusals += 1;
|
|
168
|
+
const n = this._consentCooldown.consecutiveRefusals;
|
|
169
|
+
// 1h → 4h → 24h, then 24h forever
|
|
170
|
+
const COOLDOWNS_MS = [60 * 60_000, 4 * 60 * 60_000, 24 * 60 * 60_000];
|
|
171
|
+
const idx = Math.min(n - 1, COOLDOWNS_MS.length - 1);
|
|
172
|
+
const ms = COOLDOWNS_MS[idx] ?? COOLDOWNS_MS[COOLDOWNS_MS.length - 1];
|
|
173
|
+
if (ms === undefined)
|
|
174
|
+
throw new Error('unreachable: cooldown table empty');
|
|
175
|
+
this._consentCooldown.lastExtendedAt = Date.now();
|
|
176
|
+
this._consentCooldown.activeUntil = Date.now() + ms;
|
|
177
|
+
return this.consentCooldown;
|
|
178
|
+
}
|
|
179
|
+
/** Clear cooldown — called on explicit owner opt-in or fresh post-cooldown episode. */
|
|
180
|
+
clearConsentCooldown() {
|
|
181
|
+
this._consentCooldown = { consecutiveRefusals: 0, lastExtendedAt: 0, activeUntil: 0 };
|
|
182
|
+
}
|
|
183
|
+
/** True if the consent prompt is currently suppressed by the cooldown. */
|
|
184
|
+
isConsentSuppressed(now = Date.now()) {
|
|
185
|
+
return this._consentCooldown.activeUntil > now;
|
|
186
|
+
}
|
|
187
|
+
/** Snapshot current state for persistence to tunnel.json. */
|
|
188
|
+
snapshot() {
|
|
189
|
+
return {
|
|
190
|
+
version: 1,
|
|
191
|
+
lastState: this._state,
|
|
192
|
+
lastUrl: null, // populated by the manager that knows the URL
|
|
193
|
+
activeProvider: this._activeProvider,
|
|
194
|
+
rotationPending: this._rotationPending,
|
|
195
|
+
consentCooldown: this.consentCooldown,
|
|
196
|
+
episode: this._episode ? { ...this._episode, attemptedProviders: [...this._episode.attemptedProviders] } : null,
|
|
197
|
+
savedAt: new Date().toISOString(),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Restore from a persisted snapshot. Called at boot ONLY for the
|
|
202
|
+
* cooldown counter + rotationPending flag — the state itself is NOT
|
|
203
|
+
* resumed (every boot starts at `idle` and re-enters `starting` from
|
|
204
|
+
* scratch). The rotationPending flag triggers a rotation-before-
|
|
205
|
+
* traffic in the boot-recovery path; rotation clears the flag.
|
|
206
|
+
*/
|
|
207
|
+
restoreFrom(snapshot) {
|
|
208
|
+
this._consentCooldown = { ...snapshot.consentCooldown };
|
|
209
|
+
this._rotationPending = snapshot.rotationPending;
|
|
210
|
+
// _state stays 'idle' by design — boot recovery rotation runs before
|
|
211
|
+
// the new lifecycle starts.
|
|
212
|
+
}
|
|
213
|
+
/** Set the rotation-pending flag (entered on `relay-active`; cleared after rotation). */
|
|
214
|
+
setRotationPending(value) {
|
|
215
|
+
this._rotationPending = value;
|
|
216
|
+
}
|
|
217
|
+
/** Used by callers (manager) that need to validate a consent-reply against the active state. */
|
|
218
|
+
isAwaitingConsent() {
|
|
219
|
+
return this._state === 'awaiting-consent';
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=TunnelLifecycle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelLifecycle.js","sourceRoot":"","sources":["../../src/tunnel/TunnelLifecycle.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelNotifier — two-channel routing of tunnel lifecycle events to
|
|
3
|
+
* the user.
|
|
4
|
+
*
|
|
5
|
+
* Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 3.
|
|
6
|
+
*
|
|
7
|
+
* Strict channel separation (the GPT external review's CRITICAL finding):
|
|
8
|
+
* - GROUP topic (Dashboard / Lifeline fallback / General fallback):
|
|
9
|
+
* status text only. NEVER the URL, NEVER the PIN, NEVER a signed
|
|
10
|
+
* view link. Anyone in the group can read those, which would defeat
|
|
11
|
+
* the owner-only consent gate.
|
|
12
|
+
* - OWNER DM (private bot chat with the owner principal): the actual
|
|
13
|
+
* URL + PIN + signed view links. The only credential-bearing channel.
|
|
14
|
+
*
|
|
15
|
+
* Class-based throttling (verification finding V2 + GPT finding #5):
|
|
16
|
+
* - action-required: NEVER throttled within an episode. Cross-episode
|
|
17
|
+
* cooldown applies (consent prompts) but the lifecycle decides that,
|
|
18
|
+
* not the notifier.
|
|
19
|
+
* - state-change: light — at most one per (episode, state) pair, and
|
|
20
|
+
* at most one per 15 minutes within an episode on re-entry of the
|
|
21
|
+
* same state.
|
|
22
|
+
* - noise: heavy — flapping episodes collapse into one "tunnel
|
|
23
|
+
* unstable" message, suppressed for the rest of the episode.
|
|
24
|
+
*
|
|
25
|
+
* Dedup by transition epoch (the lifecycle's monotonic counter): the
|
|
26
|
+
* notifier never emits twice for the same epoch, even if the same
|
|
27
|
+
* event fires through multiple listeners.
|
|
28
|
+
*/
|
|
29
|
+
import type { TransitionEvent } from './TunnelLifecycle.js';
|
|
30
|
+
import type { ProviderName } from './TunnelProvider.js';
|
|
31
|
+
/** Message class — drives throttling policy. */
|
|
32
|
+
export type NotificationClass = 'action-required' | 'state-change' | 'noise';
|
|
33
|
+
/** Channel — drives credential-handling policy. */
|
|
34
|
+
export type NotificationChannel = 'group' | 'owner-dm';
|
|
35
|
+
export interface NotifierMessage {
|
|
36
|
+
channel: NotificationChannel;
|
|
37
|
+
class: NotificationClass;
|
|
38
|
+
text: string;
|
|
39
|
+
/** Episode this message belongs to (for cross-episode dedup). */
|
|
40
|
+
episodeId: string | null;
|
|
41
|
+
/** Transition epoch — never emit twice for the same epoch. */
|
|
42
|
+
epoch: number;
|
|
43
|
+
}
|
|
44
|
+
export interface NotifierSink {
|
|
45
|
+
/** Send a status-text message to the group topic (Dashboard preferred, Lifeline/General fallback). */
|
|
46
|
+
sendGroup(text: string): Promise<void>;
|
|
47
|
+
/** Send a credential-bearing message to the owner's private bot DM. */
|
|
48
|
+
sendOwnerDM(text: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export interface NotifierClock {
|
|
51
|
+
now(): number;
|
|
52
|
+
}
|
|
53
|
+
export interface TunnelNotifierOptions {
|
|
54
|
+
sink: NotifierSink;
|
|
55
|
+
clock?: NotifierClock;
|
|
56
|
+
/** Within-episode same-state re-entry throttle (default 15 min). */
|
|
57
|
+
stateChangeMinIntervalMs?: number;
|
|
58
|
+
/** Flap threshold — N connect/drop cycles within an episode → noise-collapse. */
|
|
59
|
+
flapThreshold?: number;
|
|
60
|
+
}
|
|
61
|
+
export declare class TunnelNotifier {
|
|
62
|
+
private readonly sink;
|
|
63
|
+
private readonly clock;
|
|
64
|
+
private readonly stateChangeMinIntervalMs;
|
|
65
|
+
private readonly flapThreshold;
|
|
66
|
+
private lastEmittedEpoch;
|
|
67
|
+
private currentEpisodeId;
|
|
68
|
+
/** Last-emitted timestamps keyed by `<state>::<channel>` so the group
|
|
69
|
+
* pointer and the owner-DM credential delivery are throttled
|
|
70
|
+
* independently — otherwise the DM gets swallowed after the group
|
|
71
|
+
* pointer fires for the same state-change. */
|
|
72
|
+
private lastEmittedAt;
|
|
73
|
+
/** Track noise-collapse emission per episode. Once emitted, further
|
|
74
|
+
* noise messages in the same episode are suppressed. */
|
|
75
|
+
private noiseEmittedThisEpisode;
|
|
76
|
+
private flapCycles;
|
|
77
|
+
private flapCollapsed;
|
|
78
|
+
constructor(opts: TunnelNotifierOptions);
|
|
79
|
+
/**
|
|
80
|
+
* Drive the notifier from a lifecycle transition. Idempotent on the
|
|
81
|
+
* transition epoch — repeated calls with the same epoch are no-ops.
|
|
82
|
+
*/
|
|
83
|
+
onTransition(e: TransitionEvent): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Compose the user-facing messages for a transition. May return zero,
|
|
86
|
+
* one, or two messages (some transitions emit to BOTH channels, e.g.
|
|
87
|
+
* "back online — link in your DM" + DM with the URL).
|
|
88
|
+
*
|
|
89
|
+
* The DM credential payload is filled in by the manager via a callback
|
|
90
|
+
* (the notifier itself doesn't know URLs/PINs); for now the DM strings
|
|
91
|
+
* are placeholders that the manager substitutes before send. Future:
|
|
92
|
+
* extend onTransition signature to accept the credential payload.
|
|
93
|
+
*/
|
|
94
|
+
private composeMessages;
|
|
95
|
+
/**
|
|
96
|
+
* Throttle decision per spec Part 3:
|
|
97
|
+
* - action-required: NEVER throttled within an episode.
|
|
98
|
+
* - state-change: at most one per (episode, state, channel) combo
|
|
99
|
+
* within `stateChangeMinIntervalMs`. The channel scoping is
|
|
100
|
+
* load-bearing: the group pointer and the owner-DM credential
|
|
101
|
+
* delivery are TWO messages for the same state-change; they must
|
|
102
|
+
* both emit, not throttle each other out.
|
|
103
|
+
* - noise: emits at most once per episode. The flap-collapse
|
|
104
|
+
* message goes out the first time the threshold is crossed; all
|
|
105
|
+
* subsequent noise in the same episode is suppressed.
|
|
106
|
+
*/
|
|
107
|
+
private allow;
|
|
108
|
+
}
|
|
109
|
+
export type { ProviderName };
|
|
110
|
+
//# sourceMappingURL=TunnelNotifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelNotifier.d.ts","sourceRoot":"","sources":["../../src/tunnel/TunnelNotifier.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAwB,MAAM,sBAAsB,CAAC;AAClF,OAAO,KAAK,EAAE,YAAY,EAAyB,MAAM,qBAAqB,CAAC;AAE/E,gDAAgD;AAChD,MAAM,MAAM,iBAAiB,GAAG,iBAAiB,GAAG,cAAc,GAAG,OAAO,CAAC;AAE7E,mDAAmD;AACnD,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,UAAU,CAAC;AAEvD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,mBAAmB,CAAC;IAC7B,KAAK,EAAE,iBAAiB,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,sGAAsG;IACtG,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,uEAAuE;IACvE,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,aAAa;IAC5B,GAAG,IAAI,MAAM,CAAC;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,YAAY,CAAC;IACnB,KAAK,CAAC,EAAE,aAAa,CAAC;IACtB,oEAAoE;IACpE,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,iFAAiF;IACjF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAe;IACpC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAgB;IACtC,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAS;IAClD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IAIvC,OAAO,CAAC,gBAAgB,CAAM;IAG9B,OAAO,CAAC,gBAAgB,CAAuB;IAC/C;;;kDAG8C;IAC9C,OAAO,CAAC,aAAa,CAAkC;IACvD;4DACwD;IACxD,OAAO,CAAC,uBAAuB,CAAS;IACxC,OAAO,CAAC,UAAU,CAAK;IACvB,OAAO,CAAC,aAAa,CAAS;gBAElB,IAAI,EAAE,qBAAqB;IAOvC;;;OAGG;IACG,YAAY,CAAC,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAyCrD;;;;;;;;;OASG;IACH,OAAO,CAAC,eAAe;IA6HvB;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,KAAK;CAWd;AAmBD,YAAY,EAAE,YAAY,EAAE,CAAC"}
|