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,268 @@
|
|
|
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
|
+
export class TunnelNotifier {
|
|
30
|
+
sink;
|
|
31
|
+
clock;
|
|
32
|
+
stateChangeMinIntervalMs;
|
|
33
|
+
flapThreshold;
|
|
34
|
+
// Dedup state. The lifecycle's epoch is monotonic per manager instance,
|
|
35
|
+
// so once we've emitted for epoch N we never re-emit for N.
|
|
36
|
+
lastEmittedEpoch = -1;
|
|
37
|
+
// Per-episode bookkeeping.
|
|
38
|
+
currentEpisodeId = null;
|
|
39
|
+
/** Last-emitted timestamps keyed by `<state>::<channel>` so the group
|
|
40
|
+
* pointer and the owner-DM credential delivery are throttled
|
|
41
|
+
* independently — otherwise the DM gets swallowed after the group
|
|
42
|
+
* pointer fires for the same state-change. */
|
|
43
|
+
lastEmittedAt = new Map();
|
|
44
|
+
/** Track noise-collapse emission per episode. Once emitted, further
|
|
45
|
+
* noise messages in the same episode are suppressed. */
|
|
46
|
+
noiseEmittedThisEpisode = false;
|
|
47
|
+
flapCycles = 0;
|
|
48
|
+
flapCollapsed = false;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.sink = opts.sink;
|
|
51
|
+
this.clock = opts.clock ?? { now: () => Date.now() };
|
|
52
|
+
this.stateChangeMinIntervalMs = opts.stateChangeMinIntervalMs ?? 15 * 60_000;
|
|
53
|
+
this.flapThreshold = opts.flapThreshold ?? 3;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Drive the notifier from a lifecycle transition. Idempotent on the
|
|
57
|
+
* transition epoch — repeated calls with the same epoch are no-ops.
|
|
58
|
+
*/
|
|
59
|
+
async onTransition(e) {
|
|
60
|
+
// Dedup on epoch FIRST: never emit twice for the same epoch even if
|
|
61
|
+
// the listener fires multiple times.
|
|
62
|
+
if (e.epoch <= this.lastEmittedEpoch)
|
|
63
|
+
return;
|
|
64
|
+
this.lastEmittedEpoch = e.epoch;
|
|
65
|
+
// Reset per-episode bookkeeping when the episode id changes.
|
|
66
|
+
const newEpisodeId = e.episode?.episodeId ?? null;
|
|
67
|
+
if (newEpisodeId !== this.currentEpisodeId) {
|
|
68
|
+
this.currentEpisodeId = newEpisodeId;
|
|
69
|
+
this.lastEmittedAt = new Map();
|
|
70
|
+
this.noiseEmittedThisEpisode = false;
|
|
71
|
+
this.flapCycles = 0;
|
|
72
|
+
this.flapCollapsed = false;
|
|
73
|
+
}
|
|
74
|
+
// Build the message for THIS transition (if any).
|
|
75
|
+
const messages = this.composeMessages(e);
|
|
76
|
+
for (const m of messages) {
|
|
77
|
+
const allowed = this.allow(m, e.to);
|
|
78
|
+
if (!allowed)
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
if (m.channel === 'group') {
|
|
82
|
+
await this.sink.sendGroup(m.text);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
await this.sink.sendOwnerDM(m.text);
|
|
86
|
+
}
|
|
87
|
+
// Update bookkeeping AFTER successful send (cheap-best-effort).
|
|
88
|
+
const key = throttleKey(e.to, m.channel);
|
|
89
|
+
this.lastEmittedAt.set(key, this.clock.now());
|
|
90
|
+
if (m.class === 'noise') {
|
|
91
|
+
this.noiseEmittedThisEpisode = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Never throw into the tunnel path — preserves existing
|
|
96
|
+
// .catch(() => {}) semantics from server.ts.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Compose the user-facing messages for a transition. May return zero,
|
|
102
|
+
* one, or two messages (some transitions emit to BOTH channels, e.g.
|
|
103
|
+
* "back online — link in your DM" + DM with the URL).
|
|
104
|
+
*
|
|
105
|
+
* The DM credential payload is filled in by the manager via a callback
|
|
106
|
+
* (the notifier itself doesn't know URLs/PINs); for now the DM strings
|
|
107
|
+
* are placeholders that the manager substitutes before send. Future:
|
|
108
|
+
* extend onTransition signature to accept the credential payload.
|
|
109
|
+
*/
|
|
110
|
+
composeMessages(e) {
|
|
111
|
+
const ep = e.episode?.episodeId ?? null;
|
|
112
|
+
const reason = e.lastFailureReason;
|
|
113
|
+
const messages = [];
|
|
114
|
+
switch (e.to) {
|
|
115
|
+
case 'retrying':
|
|
116
|
+
// First Tier-1 failure — describe the cause, no DM.
|
|
117
|
+
messages.push({
|
|
118
|
+
channel: 'group',
|
|
119
|
+
class: 'state-change',
|
|
120
|
+
text: `Couldn't reach the usual Cloudflare tunnel${reasonSuffix(reason)}; still retrying.`,
|
|
121
|
+
episodeId: ep,
|
|
122
|
+
epoch: e.epoch,
|
|
123
|
+
});
|
|
124
|
+
// Track for flap detection.
|
|
125
|
+
this.flapCycles += 1;
|
|
126
|
+
if (this.flapCycles >= this.flapThreshold && !this.flapCollapsed) {
|
|
127
|
+
this.flapCollapsed = true;
|
|
128
|
+
messages.push({
|
|
129
|
+
channel: 'group',
|
|
130
|
+
class: 'noise',
|
|
131
|
+
text: `Tunnel is unstable right now — still working on it. I'll stop re-pinging this until something changes.`,
|
|
132
|
+
episodeId: ep,
|
|
133
|
+
epoch: e.epoch,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
break;
|
|
137
|
+
case 'active':
|
|
138
|
+
if (e.from === 'self-healing' || e.from === 'relay-active') {
|
|
139
|
+
// Self-healed back to Tier 1.
|
|
140
|
+
messages.push({
|
|
141
|
+
channel: 'group',
|
|
142
|
+
class: 'state-change',
|
|
143
|
+
text: `Your permanent link is back. New link in your DM.`,
|
|
144
|
+
episodeId: ep,
|
|
145
|
+
epoch: e.epoch,
|
|
146
|
+
});
|
|
147
|
+
messages.push({
|
|
148
|
+
channel: 'owner-dm',
|
|
149
|
+
class: 'state-change',
|
|
150
|
+
text: `__OWNER_DM_RESTORED_URL_PLACEHOLDER__`,
|
|
151
|
+
episodeId: ep,
|
|
152
|
+
epoch: e.epoch,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
else if (e.from === 'retrying') {
|
|
156
|
+
// Cloudflare came back during retry.
|
|
157
|
+
messages.push({
|
|
158
|
+
channel: 'group',
|
|
159
|
+
class: 'state-change',
|
|
160
|
+
text: `Back online. Link in your DM.`,
|
|
161
|
+
episodeId: ep,
|
|
162
|
+
epoch: e.epoch,
|
|
163
|
+
});
|
|
164
|
+
messages.push({
|
|
165
|
+
channel: 'owner-dm',
|
|
166
|
+
class: 'state-change',
|
|
167
|
+
text: `__OWNER_DM_RECOVERED_URL_PLACEHOLDER__`,
|
|
168
|
+
episodeId: ep,
|
|
169
|
+
epoch: e.epoch,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Other 'active' arrivals (initial startup) are non-events from the user's perspective.
|
|
173
|
+
break;
|
|
174
|
+
case 'awaiting-consent':
|
|
175
|
+
// Consent prompt: owner DM only, brief group pointer.
|
|
176
|
+
messages.push({
|
|
177
|
+
channel: 'group',
|
|
178
|
+
class: 'state-change',
|
|
179
|
+
text: `Cloudflare is down. I've messaged you in DM about a possible backup.`,
|
|
180
|
+
episodeId: ep,
|
|
181
|
+
epoch: e.epoch,
|
|
182
|
+
});
|
|
183
|
+
messages.push({
|
|
184
|
+
channel: 'owner-dm',
|
|
185
|
+
class: 'action-required',
|
|
186
|
+
text: `__OWNER_DM_CONSENT_PROMPT_PLACEHOLDER__`,
|
|
187
|
+
episodeId: ep,
|
|
188
|
+
epoch: e.epoch,
|
|
189
|
+
});
|
|
190
|
+
break;
|
|
191
|
+
case 'relay-active':
|
|
192
|
+
// A Tier-2 relay is up: group pointer + DM with URL + rotated PIN.
|
|
193
|
+
messages.push({
|
|
194
|
+
channel: 'group',
|
|
195
|
+
class: 'state-change',
|
|
196
|
+
text: `Backup tunnel is up. New link in your DM.`,
|
|
197
|
+
episodeId: ep,
|
|
198
|
+
epoch: e.epoch,
|
|
199
|
+
});
|
|
200
|
+
messages.push({
|
|
201
|
+
channel: 'owner-dm',
|
|
202
|
+
class: 'state-change',
|
|
203
|
+
text: `__OWNER_DM_RELAY_URL_PLACEHOLDER__`,
|
|
204
|
+
episodeId: ep,
|
|
205
|
+
epoch: e.epoch,
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
case 'exhausted':
|
|
209
|
+
messages.push({
|
|
210
|
+
channel: 'group',
|
|
211
|
+
class: 'state-change',
|
|
212
|
+
text: `All tunnel options are unavailable right now. I'll keep retrying Cloudflare in the background and switch back the moment it recovers — no restart needed.`,
|
|
213
|
+
episodeId: ep,
|
|
214
|
+
epoch: e.epoch,
|
|
215
|
+
});
|
|
216
|
+
break;
|
|
217
|
+
case 'self-healing':
|
|
218
|
+
// No user-visible message at entry. The "permanent link is back"
|
|
219
|
+
// emits on self-healing → active (above).
|
|
220
|
+
break;
|
|
221
|
+
// No notification on idle / starting transitions.
|
|
222
|
+
case 'idle':
|
|
223
|
+
case 'starting':
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
return messages;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Throttle decision per spec Part 3:
|
|
230
|
+
* - action-required: NEVER throttled within an episode.
|
|
231
|
+
* - state-change: at most one per (episode, state, channel) combo
|
|
232
|
+
* within `stateChangeMinIntervalMs`. The channel scoping is
|
|
233
|
+
* load-bearing: the group pointer and the owner-DM credential
|
|
234
|
+
* delivery are TWO messages for the same state-change; they must
|
|
235
|
+
* both emit, not throttle each other out.
|
|
236
|
+
* - noise: emits at most once per episode. The flap-collapse
|
|
237
|
+
* message goes out the first time the threshold is crossed; all
|
|
238
|
+
* subsequent noise in the same episode is suppressed.
|
|
239
|
+
*/
|
|
240
|
+
allow(m, toState) {
|
|
241
|
+
if (m.class === 'action-required')
|
|
242
|
+
return true;
|
|
243
|
+
if (m.class === 'noise') {
|
|
244
|
+
return !this.noiseEmittedThisEpisode;
|
|
245
|
+
}
|
|
246
|
+
// state-change: keyed per (state, channel).
|
|
247
|
+
const key = throttleKey(toState, m.channel);
|
|
248
|
+
const lastAt = this.lastEmittedAt.get(key);
|
|
249
|
+
if (lastAt === undefined)
|
|
250
|
+
return true;
|
|
251
|
+
return this.clock.now() - lastAt >= this.stateChangeMinIntervalMs;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function throttleKey(state, channel) {
|
|
255
|
+
return `${state}::${channel}`;
|
|
256
|
+
}
|
|
257
|
+
function reasonSuffix(reason) {
|
|
258
|
+
switch (reason) {
|
|
259
|
+
case 'rate-limited': return ' (Cloudflare is rate-limiting us)';
|
|
260
|
+
case 'binary-missing': return ' (the tunnel software is missing)';
|
|
261
|
+
case 'network': return ' (network is unreachable)';
|
|
262
|
+
case 'timeout': return ' (it timed out connecting)';
|
|
263
|
+
case 'process-exit': return ' (the tunnel process exited)';
|
|
264
|
+
case 'reachability-failed': return ' (the link did not respond)';
|
|
265
|
+
default: return '';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
//# sourceMappingURL=TunnelNotifier.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelNotifier.js","sourceRoot":"","sources":["../../src/tunnel/TunnelNotifier.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelProvider — abstraction over the underlying tunnel implementation.
|
|
3
|
+
*
|
|
4
|
+
* Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 1:
|
|
5
|
+
* the TunnelManager is the single owner of the lifecycle state machine,
|
|
6
|
+
* and each backend (cloudflared quick, cloudflared named, localtunnel,
|
|
7
|
+
* bore) implements this interface. The manager treats providers as
|
|
8
|
+
* interchangeable units; trust-tier ordering (Tier 1 vs Tier 2) and the
|
|
9
|
+
* consent gate live in the manager, NOT in the providers themselves.
|
|
10
|
+
*
|
|
11
|
+
* Two tiers:
|
|
12
|
+
* - Tier 1: automatic, secure. Cloudflare providers. Tried silently.
|
|
13
|
+
* - Tier 2: consent-gated relays. localtunnel, bore. Never activated
|
|
14
|
+
* without explicit owner approval recorded against the episode.
|
|
15
|
+
*
|
|
16
|
+
* Every provider's `start()` MUST resolve only after the URL has passed
|
|
17
|
+
* a post-start reachability probe (verified by the manager via the
|
|
18
|
+
* /health endpoint through the public URL). A URL that emits from
|
|
19
|
+
* cloudflared but doesn't actually serve (e.g. localtunnel interstitial,
|
|
20
|
+
* bore.pub down) is NOT counted as `active` — that prevents a false
|
|
21
|
+
* "back online" broadcast for a dead link.
|
|
22
|
+
*
|
|
23
|
+
* The provider does NOT decide retry, fallback, or notification — those
|
|
24
|
+
* are the manager's responsibility. Provider responsibilities are
|
|
25
|
+
* narrowly: spawn the backend, surface the URL, surface a stop handle.
|
|
26
|
+
*/
|
|
27
|
+
/** Trust tier — Tier 1 is automatic/secure, Tier 2 is consent-gated. */
|
|
28
|
+
export type ProviderTier = 1 | 2;
|
|
29
|
+
/** Stable identifier the manager uses to route episode/consent records. */
|
|
30
|
+
export type ProviderName = 'cloudflare-named' | 'cloudflare-quick' | 'localtunnel' | 'bore';
|
|
31
|
+
export interface TunnelProviderHandle {
|
|
32
|
+
/** The public URL the provider exposes. */
|
|
33
|
+
readonly url: string;
|
|
34
|
+
/**
|
|
35
|
+
* Stop the provider. MUST escalate SIGINT → SIGKILL with PID
|
|
36
|
+
* verification (mirror the existing `forceStop()` pattern) so the
|
|
37
|
+
* manager can guarantee no relay child lingers across a self-heal
|
|
38
|
+
* switch-back. Tunnel teardown is security-load-bearing for Tier 2.
|
|
39
|
+
*/
|
|
40
|
+
stop(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface TunnelProvider {
|
|
43
|
+
/** Stable name routed through episode/consent records and audit logs. */
|
|
44
|
+
readonly name: ProviderName;
|
|
45
|
+
/** Trust tier — manager uses this to decide whether the consent gate applies. */
|
|
46
|
+
readonly tier: ProviderTier;
|
|
47
|
+
/**
|
|
48
|
+
* True if the provider can be started in this environment without further
|
|
49
|
+
* user setup. Used by the manager to filter the provider pool BEFORE
|
|
50
|
+
* declaring `exhausted`. Reasons for `false`: required binary missing,
|
|
51
|
+
* required token/credentials not configured, OS unsupported, etc.
|
|
52
|
+
*
|
|
53
|
+
* The manager calls this once per episode (cheap signal), not per attempt.
|
|
54
|
+
*/
|
|
55
|
+
isAvailable(): Promise<boolean>;
|
|
56
|
+
/**
|
|
57
|
+
* Start the provider against the given local port. Resolves with the
|
|
58
|
+
* URL + a stop handle when the URL is emitted by the backend. The
|
|
59
|
+
* manager performs the reachability probe AFTER resolution and before
|
|
60
|
+
* transitioning to `active`; providers MUST NOT do their own probing
|
|
61
|
+
* here (the manager owns post-start verification per spec Part 1).
|
|
62
|
+
*
|
|
63
|
+
* Implementations should reject with a structured Error whose `message`
|
|
64
|
+
* carries the classification the manager will surface to the notifier
|
|
65
|
+
* (e.g. "rate-limited (HTTP 429 / Cloudflare 1015)", "binary missing",
|
|
66
|
+
* "network unreachable", "process exited code 1"). The manager parses
|
|
67
|
+
* these strings into ProviderFailureReason classifications.
|
|
68
|
+
*/
|
|
69
|
+
start(localPort: number): Promise<TunnelProviderHandle>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Classification the manager applies to a provider failure for the
|
|
73
|
+
* notifier and for deciding whether to advance to the next provider.
|
|
74
|
+
*
|
|
75
|
+
* The classification is owned by the manager (not the provider) per the
|
|
76
|
+
* signal-vs-authority discipline: providers raise errors with reason
|
|
77
|
+
* strings; the manager classifies into this enum and routes accordingly.
|
|
78
|
+
*/
|
|
79
|
+
export type ProviderFailureReason = 'rate-limited' | 'binary-missing' | 'network' | 'process-exit' | 'timeout' | 'reachability-failed' | 'unknown';
|
|
80
|
+
//# sourceMappingURL=TunnelProvider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelProvider.d.ts","sourceRoot":"","sources":["../../src/tunnel/TunnelProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,wEAAwE;AACxE,MAAM,MAAM,YAAY,GAAG,CAAC,GAAG,CAAC,CAAC;AAEjC,2EAA2E;AAC3E,MAAM,MAAM,YAAY,GACpB,kBAAkB,GAClB,kBAAkB,GAClB,aAAa,GACb,MAAM,CAAC;AAEX,MAAM,WAAW,oBAAoB;IACnC,2CAA2C;IAC3C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAE5B,iFAAiF;IACjF,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAE5B;;;;;;;OAOG;IACH,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IAEhC;;;;;;;;;;;;OAYG;IACH,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAAC;CACzD;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,qBAAqB,GAC7B,cAAc,GACd,gBAAgB,GAChB,SAAS,GACT,cAAc,GACd,SAAS,GACT,qBAAqB,GACrB,SAAS,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TunnelProvider — abstraction over the underlying tunnel implementation.
|
|
3
|
+
*
|
|
4
|
+
* Per spec specs/dev-infrastructure/tunnel-failure-resilience.md Part 1:
|
|
5
|
+
* the TunnelManager is the single owner of the lifecycle state machine,
|
|
6
|
+
* and each backend (cloudflared quick, cloudflared named, localtunnel,
|
|
7
|
+
* bore) implements this interface. The manager treats providers as
|
|
8
|
+
* interchangeable units; trust-tier ordering (Tier 1 vs Tier 2) and the
|
|
9
|
+
* consent gate live in the manager, NOT in the providers themselves.
|
|
10
|
+
*
|
|
11
|
+
* Two tiers:
|
|
12
|
+
* - Tier 1: automatic, secure. Cloudflare providers. Tried silently.
|
|
13
|
+
* - Tier 2: consent-gated relays. localtunnel, bore. Never activated
|
|
14
|
+
* without explicit owner approval recorded against the episode.
|
|
15
|
+
*
|
|
16
|
+
* Every provider's `start()` MUST resolve only after the URL has passed
|
|
17
|
+
* a post-start reachability probe (verified by the manager via the
|
|
18
|
+
* /health endpoint through the public URL). A URL that emits from
|
|
19
|
+
* cloudflared but doesn't actually serve (e.g. localtunnel interstitial,
|
|
20
|
+
* bore.pub down) is NOT counted as `active` — that prevents a false
|
|
21
|
+
* "back online" broadcast for a dead link.
|
|
22
|
+
*
|
|
23
|
+
* The provider does NOT decide retry, fallback, or notification — those
|
|
24
|
+
* are the manager's responsibility. Provider responsibilities are
|
|
25
|
+
* narrowly: spawn the backend, surface the URL, surface a stop handle.
|
|
26
|
+
*/
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=TunnelProvider.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TunnelProvider.js","sourceRoot":"","sources":["../../src/tunnel/TunnelProvider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-05-
|
|
5
|
-
"instarVersion": "1.2.
|
|
4
|
+
"generatedAt": "2026-05-22T22:17:56.523Z",
|
|
5
|
+
"instarVersion": "1.2.35",
|
|
6
6
|
"entryCount": 191,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- bump: patch -->
|
|
4
|
+
<!-- patch = foundation layer for an upcoming feature; no user-visible behavior change yet -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
**feat(tunnel): foundation modules for the tunnel-failure-resilience feature.**
|
|
9
|
+
|
|
10
|
+
This release lands the foundation layer that the upcoming tunnel-failure-
|
|
11
|
+
resilience feature is built on. Spec
|
|
12
|
+
`specs/dev-infrastructure/tunnel-failure-resilience.md` is converged +
|
|
13
|
+
approved (4 review iterations, 41 material findings folded in across
|
|
14
|
+
internal multi-angle review + GPT external review + GPT verification);
|
|
15
|
+
this PR is the first of a chain landing the implementation.
|
|
16
|
+
|
|
17
|
+
What this PR adds (additive only — no existing behavior changes yet):
|
|
18
|
+
|
|
19
|
+
1. **`TunnelProvider` interface** (`src/tunnel/TunnelProvider.ts`) — a
|
|
20
|
+
provider abstraction that future tunnel providers (localtunnel, bore)
|
|
21
|
+
will implement alongside the two extracted Cloudflare providers.
|
|
22
|
+
Defines `ProviderTier`, `ProviderName`, `ProviderFailureReason`.
|
|
23
|
+
|
|
24
|
+
2. **`CloudflareQuickProvider`** (`src/tunnel/CloudflareQuickProvider.ts`)
|
|
25
|
+
— Tier-1 zero-config Cloudflare quick-tunnel implementation
|
|
26
|
+
extracted from the original `TunnelManager.startQuickTunnel`. Owns
|
|
27
|
+
ONLY spawn + URL emission + teardown; retry/reconnect ownership
|
|
28
|
+
moves to the manager in a subsequent PR per the spec's single-owner
|
|
29
|
+
mandate. Forceful stop with SIGINT→SIGKILL escalation preserved.
|
|
30
|
+
|
|
31
|
+
3. **`CloudflareNamedProvider`** (`src/tunnel/CloudflareNamedProvider.ts`)
|
|
32
|
+
— Tier-1 persistent Cloudflare named-tunnel implementation
|
|
33
|
+
(token-auth and config-file-auth modes). `isAvailable()` returns
|
|
34
|
+
false when neither token nor configFile is configured.
|
|
35
|
+
|
|
36
|
+
4. **`TunnelLifecycle`** (`src/tunnel/TunnelLifecycle.ts`) — the single-
|
|
37
|
+
writer state machine that will own the tunnel lifecycle once the
|
|
38
|
+
manager is rewritten. Compare-and-swap `transition(expectedFrom, to)`
|
|
39
|
+
guard rejects losing concurrent writes (the error+exit double-handler
|
|
40
|
+
race fix). Monotonic transition epoch drives notification dedup.
|
|
41
|
+
Episode model + cross-episode consent cooldown + rotation-pending
|
|
42
|
+
flag (the boot-recovery mechanism for crash-safe credential
|
|
43
|
+
rotation) all present and tested.
|
|
44
|
+
|
|
45
|
+
5. **`TunnelNotifier`** (`src/tunnel/TunnelNotifier.ts`) — two-channel
|
|
46
|
+
routing that, once wired in, will route group-topic status text
|
|
47
|
+
(no credentials, ever) separately from owner-DM credential delivery
|
|
48
|
+
(the only credential-bearing channel). Class-based throttling:
|
|
49
|
+
`action-required` (consent prompts) never throttled within an
|
|
50
|
+
episode; `state-change` keyed per `(state, channel)` within a 15-min
|
|
51
|
+
window; `noise` (flap collapse) at most once per episode.
|
|
52
|
+
|
|
53
|
+
The existing `TunnelManager.ts` and `server.ts` tunnel block are
|
|
54
|
+
UNTOUCHED in this PR — current tunnel behavior is identical. Wiring
|
|
55
|
+
the new modules into the manager (and retiring the legacy `server.ts`
|
|
56
|
+
retry ladders) is the next PR in this chain.
|
|
57
|
+
|
|
58
|
+
## Evidence
|
|
59
|
+
|
|
60
|
+
54 new unit tests pass:
|
|
61
|
+
|
|
62
|
+
- `tests/unit/tunnel-providers.test.ts` — 10 tests covering provider
|
|
63
|
+
interface assertions, `isAvailable()` semantics on token/configFile
|
|
64
|
+
configurations, start-rejection paths on missing prerequisites.
|
|
65
|
+
- `tests/unit/tunnel-lifecycle.test.ts` — 32 tests covering the CAS
|
|
66
|
+
guard (rejects losing transitions without mutating state),
|
|
67
|
+
monotonic-epoch increment, valid-transition map enforcement,
|
|
68
|
+
episode lifecycle, exponential consent-cooldown back-off,
|
|
69
|
+
rotation-pending persistence, failure-reason classification,
|
|
70
|
+
CSPRNG nonce generation.
|
|
71
|
+
- `tests/unit/tunnel-notifier.test.ts` — 12 tests covering the
|
|
72
|
+
channel-separation invariant (credentials never appear in group
|
|
73
|
+
messages), epoch dedup, action-required-never-throttled, state-
|
|
74
|
+
change per-(state, channel) throttling, flap-collapse emits
|
|
75
|
+
exactly once per episode, sink-error swallowing.
|
|
76
|
+
|
|
77
|
+
All existing tests still pass. `tsc --noEmit` clean. `npm run lint`
|
|
78
|
+
clean.
|
|
79
|
+
|
|
80
|
+
## What to Tell Your User
|
|
81
|
+
|
|
82
|
+
This release adds internal scaffolding for a tunnel-resilience feature
|
|
83
|
+
coming over the next few releases. You will not notice any behavior
|
|
84
|
+
change yet — your dashboard link comes up the same way as before. The
|
|
85
|
+
upcoming releases will add failure notification to the Dashboard topic,
|
|
86
|
+
backup tunnel providers (asked about in DM before use), and automatic
|
|
87
|
+
recovery when Cloudflare comes back online. Each piece ships in its
|
|
88
|
+
own release in the chain that starts here.
|
|
89
|
+
|
|
90
|
+
## Summary of New Capabilities
|
|
91
|
+
|
|
92
|
+
| Capability | How to Use |
|
|
93
|
+
|-----------|-----------|
|
|
94
|
+
| Provider abstraction internals | Internal — used by the upcoming manager rewrite |
|
|
95
|
+
| Single-writer state machine internals | Internal — used by the upcoming manager rewrite |
|
|
96
|
+
| Two-channel notifier internals | Internal — wired up in the next release |
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Side-effects review — tunnel failure resilience (foundation checkpoint)
|
|
2
|
+
|
|
3
|
+
**Scope (this commit — checkpoint 1 of N):** Land the foundation layer
|
|
4
|
+
that the rest of the feature builds on. This checkpoint introduces
|
|
5
|
+
NEW modules in isolation; it does NOT yet rewire `TunnelManager.ts` or
|
|
6
|
+
`server.ts`, so existing tunnel behavior is unchanged. Subsequent
|
|
7
|
+
commits on this branch layer on (1) `TunnelManager` rewrite to drive
|
|
8
|
+
the new lifecycle, (2) localtunnel provider + consent flow, (3)
|
|
9
|
+
`TelegramAdapter.sendToOwnerDM` + inline-button consent UX, (4)
|
|
10
|
+
`authToken`/PIN rotation lifecycle + boot recovery, (5) self-heal probe,
|
|
11
|
+
(6) `/tunnel` route, (7) `ConfigDefaults` migration + CLAUDE.md template
|
|
12
|
+
update, (8) Tier-2/3 tests. The PR opens once the full feature lands.
|
|
13
|
+
|
|
14
|
+
**Files touched in this checkpoint:**
|
|
15
|
+
- `specs/dev-infrastructure/tunnel-failure-resilience.md` — fix
|
|
16
|
+
`eli16-overview` frontmatter pointer to the actual `.eli16.md` file
|
|
17
|
+
(was pointing at a section anchor in the convergence report, which
|
|
18
|
+
the gate cannot resolve).
|
|
19
|
+
- `specs/dev-infrastructure/tunnel-failure-resilience.eli16.md` —
|
|
20
|
+
recreated (the original was lost in a worktree rebase).
|
|
21
|
+
- `src/tunnel/TunnelProvider.ts` — NEW. Provider abstraction interface
|
|
22
|
+
+ `ProviderName`/`ProviderTier`/`ProviderFailureReason` enums.
|
|
23
|
+
- `src/tunnel/CloudflareQuickProvider.ts` — NEW. Tier-1 quick-tunnel
|
|
24
|
+
provider extracted from the original `TunnelManager.startQuickTunnel`.
|
|
25
|
+
- `src/tunnel/CloudflareNamedProvider.ts` — NEW. Tier-1 named-tunnel
|
|
26
|
+
provider (token-auth + config-file-auth) extracted from the
|
|
27
|
+
original `TunnelManager.startNamedTunnel` + `startConfigFileTunnel`.
|
|
28
|
+
- `src/tunnel/TunnelLifecycle.ts` — NEW. Single-writer CAS-guarded
|
|
29
|
+
state machine for the tunnel lifecycle (idle → starting → active |
|
|
30
|
+
retrying → awaiting-consent → relay-active | self-healing |
|
|
31
|
+
exhausted). Episode model, cross-episode consent cooldown,
|
|
32
|
+
rotation-pending flag, monotonic transition epoch.
|
|
33
|
+
- `src/tunnel/TunnelNotifier.ts` — NEW. Two-channel routing (group
|
|
34
|
+
status text only; owner-DM credentials only) with class-based
|
|
35
|
+
throttling (action-required never throttled; state-change keyed per
|
|
36
|
+
(state, channel) within window; noise emits once per episode).
|
|
37
|
+
- `tests/unit/tunnel-providers.test.ts` — NEW. 10 tests covering
|
|
38
|
+
provider interface + `isAvailable` semantics + start-rejection paths.
|
|
39
|
+
- `tests/unit/tunnel-lifecycle.test.ts` — NEW. 32 tests covering CAS
|
|
40
|
+
guard, transition validity, episode lifecycle, consent cooldown
|
|
41
|
+
exponential backoff, rotation flag, classifyFailure, generators.
|
|
42
|
+
- `tests/unit/tunnel-notifier.test.ts` — NEW. 12 tests covering
|
|
43
|
+
channel separation (no credentials in group), epoch dedup, class
|
|
44
|
+
throttling, flap-collapse, sink-error swallowing.
|
|
45
|
+
|
|
46
|
+
**Under-block**: None introduced by this checkpoint. The new modules
|
|
47
|
+
are not yet wired into the live tunnel path — existing
|
|
48
|
+
`TunnelManager.ts` is untouched and behaves identically. The wire-up
|
|
49
|
+
happens in the next commit on this branch.
|
|
50
|
+
|
|
51
|
+
**Over-block**: None. The new modules are additive; no existing
|
|
52
|
+
control-flow is changed yet.
|
|
53
|
+
|
|
54
|
+
**Level-of-abstraction fit**:
|
|
55
|
+
- `TunnelProvider` is a pure interface; concrete providers are thin
|
|
56
|
+
wrappers around `cloudflared`.
|
|
57
|
+
- `TunnelLifecycle` is provider-agnostic and notification-agnostic; it
|
|
58
|
+
owns ONLY state.
|
|
59
|
+
- `TunnelNotifier` is lifecycle-aware (consumes transition events) but
|
|
60
|
+
channel-agnostic (delegates to a `NotifierSink` interface).
|
|
61
|
+
- Separation lets subsequent commits swap implementations (e.g., add
|
|
62
|
+
the localtunnel provider) without touching the lifecycle or notifier.
|
|
63
|
+
|
|
64
|
+
**Signal vs authority**: Compliant. Providers raise failure errors
|
|
65
|
+
(SIGNAL via Error.message classification); the lifecycle classifies
|
|
66
|
+
into `ProviderFailureReason` (AUTHORITY); the notifier consumes the
|
|
67
|
+
classified signal (READ-ONLY consumer). No new authority is introduced
|
|
68
|
+
at the wrong layer.
|
|
69
|
+
|
|
70
|
+
**Interactions**:
|
|
71
|
+
- New modules import only from each other and node:crypto / node:events
|
|
72
|
+
/ cloudflared. No interaction with existing TelegramAdapter,
|
|
73
|
+
PostUpdateMigrator, or server.ts in this checkpoint.
|
|
74
|
+
- Notifier's `NotifierSink` interface is satisfied by the existing
|
|
75
|
+
`TelegramAdapter.sendToTopic` for the group channel; the owner-DM
|
|
76
|
+
send method (`sendOwnerDM`) does not exist yet and will be added in
|
|
77
|
+
a subsequent commit. Until then, tests use mock sinks.
|
|
78
|
+
|
|
79
|
+
**External surfaces**: None. No new API endpoint, no new CLI command,
|
|
80
|
+
no new config field user-facing. The new types are exported from
|
|
81
|
+
`src/tunnel/*` but no caller imports them in this checkpoint.
|
|
82
|
+
|
|
83
|
+
**Migration parity**: N/A for this checkpoint (no agent-installed
|
|
84
|
+
file changes). The migration story (ConfigDefaults entries, CLAUDE.md
|
|
85
|
+
template + migrator updates) lands in a later commit on this branch.
|
|
86
|
+
|
|
87
|
+
**Rollback cost**: Trivial. Revert these eight new files + the spec
|
|
88
|
+
frontmatter pointer fix; the eli16.md companion goes with them.
|
|
89
|
+
Nothing else changes.
|
|
90
|
+
|
|
91
|
+
**Tests**:
|
|
92
|
+
- 10/10 tests in `tests/unit/tunnel-providers.test.ts` pass.
|
|
93
|
+
- 32/32 tests in `tests/unit/tunnel-lifecycle.test.ts` pass.
|
|
94
|
+
- 12/12 tests in `tests/unit/tunnel-notifier.test.ts` pass.
|
|
95
|
+
- `tsc --noEmit` clean. `npm run lint` clean.
|
|
96
|
+
- No existing tests were modified; no regressions.
|
|
97
|
+
|
|
98
|
+
**Decision-point inventory**:
|
|
99
|
+
1. Provider interface is named-provider-aware (`isAvailable()` returns
|
|
100
|
+
false when neither token nor configFile is configured) so the
|
|
101
|
+
manager naturally skips the named path on quick-only installs.
|
|
102
|
+
2. Lifecycle uses CAS (`transition(expectedFrom, to)`) instead of
|
|
103
|
+
message-passing — Node single-threaded model makes the synchronous
|
|
104
|
+
compare-and-set sufficient; the alternative (a queue) was unnecessary
|
|
105
|
+
complexity.
|
|
106
|
+
3. `noiseEmittedThisEpisode` is a per-episode boolean; the flap-
|
|
107
|
+
collapse message lands at most once per episode regardless of how
|
|
108
|
+
many flap cycles happen. Trades fidelity (which cycle triggered it)
|
|
109
|
+
for guaranteed non-flooding.
|
|
110
|
+
4. Throttle key for state-change is `<state>::<channel>` — the group
|
|
111
|
+
pointer and owner-DM credential delivery for the same state-change
|
|
112
|
+
must both be allowed; channel-blind keying would suppress the DM
|
|
113
|
+
after the group pointer fired, which is exactly the bug the
|
|
114
|
+
unit tests caught during this checkpoint's build.
|
|
115
|
+
5. `TunnelLifecycle.restoreFrom` does NOT resume the live state.
|
|
116
|
+
Every boot starts at `idle` and re-enters `starting` from scratch.
|
|
117
|
+
The persisted `rotationPending` flag is the only piece that
|
|
118
|
+
triggers a boot-time action (the rotation in checkpoint 4).
|