@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.0

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.
Files changed (116) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/icon.svg +3 -0
  5. package/lib/client.js +396 -132
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +604 -262
  8. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  9. package/lib/types/client/FenceNotice.js +0 -3
  10. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +3 -2
  13. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +14 -9
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +13 -4
  17. package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +14 -8
  20. package/lib/types/client/UpdateEntry.js +2 -2
  21. package/lib/types/client/UpdatePanel.js +2 -2
  22. package/lib/types/client/deep-link.js +2 -2
  23. package/lib/types/client/index.d.ts +16 -8
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +56 -32
  26. package/lib/types/client/locales.d.ts +0 -2
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +6 -8
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  30. package/lib/types/client/mobile-adapt.js +143 -28
  31. package/lib/types/client/pair-api.d.ts +32 -3
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +44 -6
  34. package/lib/types/client/plugin-card-seat.d.ts +104 -0
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  36. package/lib/types/client/plugin-card-seat.js +143 -0
  37. package/lib/types/client/remote-channel.d.ts.map +1 -1
  38. package/lib/types/client/remote-channel.js +18 -5
  39. package/lib/types/client/settings-form.d.ts +17 -17
  40. package/lib/types/client/settings-form.d.ts.map +1 -1
  41. package/lib/types/client/settings-form.js +30 -27
  42. package/lib/types/console-output.d.ts +41 -0
  43. package/lib/types/console-output.d.ts.map +1 -0
  44. package/lib/types/console-output.js +89 -0
  45. package/lib/types/http.d.ts +18 -1
  46. package/lib/types/http.d.ts.map +1 -1
  47. package/lib/types/http.js +22 -1
  48. package/lib/types/index.d.ts +19 -11
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/index.js +91 -76
  51. package/lib/types/inner-auth.d.ts +3 -2
  52. package/lib/types/inner-auth.d.ts.map +1 -1
  53. package/lib/types/inner-auth.js +3 -2
  54. package/lib/types/pair-grant.d.ts +66 -0
  55. package/lib/types/pair-grant.d.ts.map +1 -0
  56. package/lib/types/pair-grant.js +95 -0
  57. package/lib/types/pairing.d.ts +44 -12
  58. package/lib/types/pairing.d.ts.map +1 -1
  59. package/lib/types/pairing.js +69 -20
  60. package/lib/types/pairing.persist.test.js +26 -0
  61. package/lib/types/posture.d.ts +11 -3
  62. package/lib/types/posture.d.ts.map +1 -1
  63. package/lib/types/posture.js +8 -3
  64. package/lib/types/remote-api.d.ts +10 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +34 -34
  67. package/lib/types/remote-channel-boot.d.ts +6 -1
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +16 -8
  70. package/lib/types/remote-channel-rules.d.ts +14 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +8 -0
  73. package/lib/types/remote-methods.d.ts +1 -1
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +1 -0
  76. package/lib/types/routes.d.ts +47 -5
  77. package/lib/types/routes.d.ts.map +1 -1
  78. package/lib/types/routes.js +160 -58
  79. package/lib/types/tunnel.d.ts +21 -6
  80. package/lib/types/tunnel.d.ts.map +1 -1
  81. package/lib/types/tunnel.js +58 -11
  82. package/lib/types/update.d.ts +4 -0
  83. package/lib/types/update.d.ts.map +1 -1
  84. package/lib/types/update.js +74 -33
  85. package/package.json +23 -20
  86. package/src/client/FenceNotice.tsx +0 -2
  87. package/src/client/PluginSettingsCard.tsx +7 -2
  88. package/src/client/RemoteEntry.tsx +14 -9
  89. package/src/client/RemotePanel.tsx +17 -7
  90. package/src/client/RemoteSettingsCard.tsx +16 -9
  91. package/src/client/UpdateEntry.tsx +2 -2
  92. package/src/client/UpdatePanel.tsx +3 -3
  93. package/src/client/deep-link.ts +2 -2
  94. package/src/client/index.ts +70 -40
  95. package/src/client/locales.ts +6 -8
  96. package/src/client/mobile-adapt.ts +136 -25
  97. package/src/client/pair-api.ts +47 -7
  98. package/src/client/plugin-card-seat.ts +192 -0
  99. package/src/client/remote-channel.ts +19 -4
  100. package/src/client/remote.module.css +38 -0
  101. package/src/client/settings-form.ts +34 -31
  102. package/src/console-output.ts +97 -0
  103. package/src/http.ts +23 -1
  104. package/src/index.ts +102 -79
  105. package/src/inner-auth.ts +3 -2
  106. package/src/pair-grant.ts +131 -0
  107. package/src/pairing.persist.test.ts +36 -0
  108. package/src/pairing.ts +75 -24
  109. package/src/posture.ts +12 -4
  110. package/src/remote-api.ts +34 -41
  111. package/src/remote-channel-boot.ts +16 -8
  112. package/src/remote-channel-rules.ts +17 -0
  113. package/src/remote-methods.ts +1 -0
  114. package/src/routes.ts +165 -57
  115. package/src/tunnel.ts +56 -11
  116. package/src/update.ts +74 -29
@@ -0,0 +1,95 @@
1
+ /**
2
+ * One-time pairing grants for the /pair-app landing.
3
+ *
4
+ * The QR entry (`/pair-accept?pair=<token>`) used to hand the freshly minted
5
+ * device id to the app landing in its own URL (`/pair-app?device=<id>`). A
6
+ * device id is a live session credential: the channel gate authorizes every
7
+ * gated request by it and the cookie it mirrors lives a year, so a URL carrying
8
+ * it leaks that credential into browser history, the address bar, and every log
9
+ * on the path. The landing is reached with a one-time grant instead — 256 bits
10
+ * of entropy, a short TTL, and single-consume semantics — and the device id
11
+ * reaches the client only inside the server-rendered shell (the capture script
12
+ * the landing patches in).
13
+ *
14
+ * Consume semantics follow the reference implementation's one-time host
15
+ * capability (zcode `packages/server/src/hostCapability.ts`): the record is
16
+ * deleted on EVERY consume attempt — success, expiry, or replay — so a captured
17
+ * grant is worthless after its first use and a replayed one can never resolve,
18
+ * and only a first use inside the TTL yields a device.
19
+ *
20
+ * @module @linxin666/dsh-remote-web-ui/pair-grant
21
+ */
22
+ import { randomBytes } from 'node:crypto';
23
+ /**
24
+ * Default grant lifetime. The grant only has to survive one redirect chain
25
+ * from the accept page to the app landing, tunnel round trips included, so it
26
+ * is far shorter than the device session it stands in for.
27
+ */
28
+ export const DEFAULT_PAIR_GRANT_TTL_MS = 60_000;
29
+ /**
30
+ * Cap on live grants. The accept page is rate-limited per source, but the
31
+ * table is still caller-driven, so it stays bounded (oldest evicted first)
32
+ * exactly like the device table.
33
+ */
34
+ export const MAX_LIVE_GRANTS = 256;
35
+ /**
36
+ * Build the grant table. Pure TypeScript with injected clock/randomness so the
37
+ * whole one-time semantics are unit-testable without a server.
38
+ */
39
+ export function createPairGrantStore(options = {}) {
40
+ const ttlMs = options.ttlMs ?? DEFAULT_PAIR_GRANT_TTL_MS;
41
+ const now = options.now ?? (() => Date.now());
42
+ const createGrant = options.createGrant ?? (() => randomBytes(32).toString('base64url'));
43
+ const entries = new Map();
44
+ const purgeExpired = (at) => {
45
+ for (const [grant, entry] of entries) {
46
+ if (entry.expiresAt <= at)
47
+ entries.delete(grant);
48
+ }
49
+ };
50
+ return {
51
+ issue(deviceId) {
52
+ const issuedAt = now();
53
+ purgeExpired(issuedAt);
54
+ while (entries.size >= MAX_LIVE_GRANTS) {
55
+ const oldest = entries.keys().next().value;
56
+ if (oldest === undefined)
57
+ break;
58
+ entries.delete(oldest);
59
+ }
60
+ const grant = createGrant();
61
+ const expiresAt = issuedAt + ttlMs;
62
+ entries.set(grant, { deviceId, expiresAt });
63
+ return { grant, expiresAt };
64
+ },
65
+ consume(grant) {
66
+ if (grant === undefined || grant === '')
67
+ return undefined;
68
+ const consumedAt = now();
69
+ const entry = entries.get(grant);
70
+ // Delete before validating: a replayed grant must never resolve, and an
71
+ // expired one must not linger in the table.
72
+ entries.delete(grant);
73
+ purgeExpired(consumedAt);
74
+ if (entry === undefined || entry.expiresAt <= consumedAt)
75
+ return undefined;
76
+ return entry.deviceId;
77
+ },
78
+ peek(grant) {
79
+ if (grant === undefined || grant === '')
80
+ return undefined;
81
+ const at = now();
82
+ const entry = entries.get(grant);
83
+ if (entry === undefined)
84
+ return undefined;
85
+ if (entry.expiresAt <= at) {
86
+ entries.delete(grant);
87
+ return undefined;
88
+ }
89
+ return entry.deviceId;
90
+ },
91
+ get size() {
92
+ return entries.size;
93
+ },
94
+ };
95
+ }
@@ -7,9 +7,9 @@
7
7
  * Security invariants:
8
8
  * - One active token at a time; `issue()` replaces it, so a refreshed QR
9
9
  * immediately invalidates the previous link.
10
- * - A token stays re-usable until it expires or is replaced: the first
11
- * successful `accept()` marks it consumed, but the same link may pair
12
- * again within the window (each re-accept mints a fresh device session).
10
+ * - A token is a bearer credential for its whole window: it is refused only
11
+ * when unknown, expired, or after `stop()`, and the same link may pair
12
+ * repeatedly within that window (each accept mints a fresh device session).
13
13
  * Mobile flows routinely split across cookie contexts (camera preview to
14
14
  * in-app browser to the system browser), and the later context must be
15
15
  * able to complete its own pairing from the same link.
@@ -39,8 +39,6 @@ export interface TokenRecord {
39
39
  issuedAt: number;
40
40
  /** Absolute expiry (ms epoch); accept() past this is refused. */
41
41
  expiresAt: number;
42
- /** Consumed by the first successful accept(). */
43
- consumed: boolean;
44
42
  /** Opaque, non-secret identifier surfaced in snapshots (never the pairing secret). */
45
43
  id: string;
46
44
  /** Workspace the QR link should land the phone in (optional). */
@@ -150,13 +148,17 @@ export interface PairingConfig {
150
148
  */
151
149
  devicesFile?: string;
152
150
  }
153
- /** Result of one accept() attempt. */
151
+ /**
152
+ * Result of one accept() attempt. A token is a bearer credential for its whole
153
+ * window (see the module doc), so the only refusal is `invalid`: unknown,
154
+ * expired, or after stop(). There is no "already used" outcome.
155
+ */
154
156
  export type AcceptResult = {
155
157
  ok: true;
156
158
  deviceId: string;
157
159
  } | {
158
160
  ok: false;
159
- code: 'invalid' | 'used';
161
+ code: 'invalid';
160
162
  };
161
163
  /** Thrown by issue() for an address outside the sampled LAN literals. */
162
164
  export declare class UnknownLanAddressError extends Error {
@@ -170,6 +172,11 @@ export interface PairingClock {
170
172
  now(): number;
171
173
  randomToken(): string;
172
174
  }
175
+ /** File-operation seam for the revocation-durability step (tests). */
176
+ export interface PairingFsSeams {
177
+ /** Remove the stale device store (defaults to fs.rmSync with force). */
178
+ removeFile?(path: string): void;
179
+ }
173
180
  /** Real clock/entropy: 32 random hex chars per token. */
174
181
  export declare const defaultClock: PairingClock;
175
182
  /**
@@ -184,6 +191,7 @@ export declare const defaultClock: PairingClock;
184
191
  export declare class PairingService {
185
192
  config: PairingConfig;
186
193
  private readonly clock;
194
+ private readonly fs;
187
195
  private readonly tokens;
188
196
  private readonly devices;
189
197
  private readonly listeners;
@@ -206,7 +214,7 @@ export declare class PairingService {
206
214
  * the current one.
207
215
  * @param clock - clock/entropy source (injectable for tests).
208
216
  */
209
- constructor(config: PairingConfig, clock?: PairingClock);
217
+ constructor(config: PairingConfig, clock?: PairingClock, fs?: PairingFsSeams);
210
218
  /**
211
219
  * Restore device sessions persisted by a previous process run. A corrupt
212
220
  * or missing file is tolerated (an empty device table, never a throw) —
@@ -228,6 +236,16 @@ export declare class PairingService {
228
236
  * atomic rename; a crash mid-write can never leave a half-written store.
229
237
  */
230
238
  private persist;
239
+ /**
240
+ * Persist a table change that REVOKES access (stop, per-device revoke, idle
241
+ * eviction). A failed write would leave the revoked session on disk, and
242
+ * {@link loadPersisted} restores that file verbatim on the next start — the
243
+ * device's still-valid cookie would authorize again after an explicit
244
+ * revocation. When the write cannot be made durable, the stale store is
245
+ * removed instead (a missing file already loads as an empty table), so the
246
+ * failure costs a re-pair rather than silently undoing the revocation.
247
+ */
248
+ private persistRevocation;
231
249
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
232
250
  get lanBaseUrl(): string | undefined;
233
251
  /** The LAN base URL for one specific literal (undefined when not constructible). */
@@ -241,7 +259,12 @@ export declare class PairingService {
241
259
  }[]): void;
242
260
  /** The configured public (tunneled) base URL, when present. */
243
261
  get publicBaseUrl(): string | undefined;
244
- /** Set or clear the public base URL (a tunnel in front of this server). */
262
+ /**
263
+ * Set or clear the public base URL (a tunnel in front of this server). The
264
+ * value is canonicalized to its origin: a trailing slash (what a browser
265
+ * address-bar copy produces) or a path would mint a dead `//pair-accept`
266
+ * link, which WHATWG resolves as an authority rather than a path.
267
+ */
245
268
  setPublicBaseUrl(url: string | undefined): void;
246
269
  /** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
247
270
  setTunnelStatus(status: TunnelStatus | undefined): void;
@@ -266,9 +289,11 @@ export declare class PairingService {
266
289
  expiresAt: number;
267
290
  };
268
291
  /**
269
- * Consume a token and bind a device session. One-time: the second
270
- * successful call for the same token is impossible because the first
271
- * consumes it.
292
+ * Redeem a token and bind a device session. The token is a bearer
293
+ * credential for its whole window, not a single-use nonce: it is refused
294
+ * only when unknown, past its expiry, or after stop(), and every successful
295
+ * call — including a repeat within the same window — mints a fresh device
296
+ * session (see the module doc for the mobile cookie-context rationale).
272
297
  * @param token - the token secret from the QR link.
273
298
  * @param userAgent - optional User-Agent header captured at accept.
274
299
  * @returns the new device id, or a refusal code.
@@ -327,6 +352,13 @@ export declare class PairingService {
327
352
  private isOnlineAt;
328
353
  private notify;
329
354
  }
355
+ /**
356
+ * Canonical origin of a public base URL. An unparsable value is returned
357
+ * verbatim so the caller's malformed-base warning stays the single owner of
358
+ * that case.
359
+ * @param url - the configured public base.
360
+ */
361
+ export declare function canonicalBaseUrl(url: string): string;
330
362
  /** Strip control characters and cap the User-Agent stored with a session. */
331
363
  export declare function sanitizeUserAgent(raw: string | undefined): string | undefined;
332
364
  //# sourceMappingURL=pairing.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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"}
@@ -7,9 +7,9 @@
7
7
  * Security invariants:
8
8
  * - One active token at a time; `issue()` replaces it, so a refreshed QR
9
9
  * immediately invalidates the previous link.
10
- * - A token stays re-usable until it expires or is replaced: the first
11
- * successful `accept()` marks it consumed, but the same link may pair
12
- * again within the window (each re-accept mints a fresh device session).
10
+ * - A token is a bearer credential for its whole window: it is refused only
11
+ * when unknown, expired, or after `stop()`, and the same link may pair
12
+ * repeatedly within that window (each accept mints a fresh device session).
13
13
  * Mobile flows routinely split across cookie contexts (camera preview to
14
14
  * in-app browser to the system browser), and the later context must be
15
15
  * able to complete its own pairing from the same link.
@@ -21,7 +21,7 @@
21
21
  * `idleExpireMs` are deleted on sweep, load, and the next gated request.
22
22
  */
23
23
  import { randomBytes } from 'node:crypto';
24
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
25
25
  import { dirname } from 'node:path';
26
26
  /**
27
27
  * Default idle-expiry window: 30 days without a heartbeat or a gated
@@ -62,6 +62,7 @@ export const defaultClock = {
62
62
  export class PairingService {
63
63
  config;
64
64
  clock;
65
+ fs;
65
66
  tokens = new Map();
66
67
  devices = new Map();
67
68
  listeners = new Set();
@@ -84,9 +85,10 @@ export class PairingService {
84
85
  * the current one.
85
86
  * @param clock - clock/entropy source (injectable for tests).
86
87
  */
87
- constructor(config, clock = defaultClock) {
88
+ constructor(config, clock = defaultClock, fs = {}) {
88
89
  this.config = config;
89
90
  this.clock = clock;
91
+ this.fs = fs;
90
92
  this.loadPersisted();
91
93
  }
92
94
  /**
@@ -119,7 +121,7 @@ export class PairingService {
119
121
  }
120
122
  this.clampToMaxDevices();
121
123
  if (this.evictIdle())
122
- this.persist();
124
+ this.persistRevocation();
123
125
  }
124
126
  catch {
125
127
  // Unreadable/corrupt: start empty rather than refusing to boot.
@@ -160,7 +162,7 @@ export class PairingService {
160
162
  persist() {
161
163
  const file = this.config.devicesFile;
162
164
  if (file === undefined)
163
- return;
165
+ return true;
164
166
  try {
165
167
  mkdirSync(dirname(file), { recursive: true });
166
168
  const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
@@ -170,9 +172,37 @@ export class PairingService {
170
172
  writeFileSync(temp, JSON.stringify(payload), { mode: 0o600 });
171
173
  renameSync(temp, file);
172
174
  this.dirty = false;
175
+ return true;
173
176
  }
174
177
  catch (error) {
175
178
  console.error('remote-web-ui: failed to persist paired devices', error);
179
+ return false;
180
+ }
181
+ }
182
+ /**
183
+ * Persist a table change that REVOKES access (stop, per-device revoke, idle
184
+ * eviction). A failed write would leave the revoked session on disk, and
185
+ * {@link loadPersisted} restores that file verbatim on the next start — the
186
+ * device's still-valid cookie would authorize again after an explicit
187
+ * revocation. When the write cannot be made durable, the stale store is
188
+ * removed instead (a missing file already loads as an empty table), so the
189
+ * failure costs a re-pair rather than silently undoing the revocation.
190
+ */
191
+ persistRevocation() {
192
+ if (this.persist())
193
+ return;
194
+ const file = this.config.devicesFile;
195
+ if (file === undefined)
196
+ return;
197
+ try {
198
+ if (this.fs.removeFile !== undefined)
199
+ this.fs.removeFile(file);
200
+ else
201
+ rmSync(file, { force: true });
202
+ console.error('remote-web-ui: the device revocation could not be persisted; removed the stale device store so a restart cannot restore it');
203
+ }
204
+ catch (error) {
205
+ console.error('remote-web-ui: the device revocation could not be persisted and the stale device store could not be removed', error);
176
206
  }
177
207
  }
178
208
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
@@ -196,9 +226,14 @@ export class PairingService {
196
226
  get publicBaseUrl() {
197
227
  return this.publicBase;
198
228
  }
199
- /** Set or clear the public base URL (a tunnel in front of this server). */
229
+ /**
230
+ * Set or clear the public base URL (a tunnel in front of this server). The
231
+ * value is canonicalized to its origin: a trailing slash (what a browser
232
+ * address-bar copy produces) or a path would mint a dead `//pair-accept`
233
+ * link, which WHATWG resolves as an authority rather than a path.
234
+ */
200
235
  setPublicBaseUrl(url) {
201
- this.publicBase = url;
236
+ this.publicBase = url === undefined ? undefined : canonicalBaseUrl(url);
202
237
  this.notify();
203
238
  }
204
239
  /** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
@@ -244,7 +279,6 @@ export class PairingService {
244
279
  id: `t${this.tokenSerial}`,
245
280
  issuedAt: now,
246
281
  expiresAt: now + this.config.tokenTtlMs,
247
- consumed: false,
248
282
  ...(workspaceId !== undefined ? { workspaceId } : {}),
249
283
  ...(address !== undefined ? { address } : {}),
250
284
  });
@@ -252,21 +286,20 @@ export class PairingService {
252
286
  return { token, expiresAt: now + this.config.tokenTtlMs };
253
287
  }
254
288
  /**
255
- * Consume a token and bind a device session. One-time: the second
256
- * successful call for the same token is impossible because the first
257
- * consumes it.
289
+ * Redeem a token and bind a device session. The token is a bearer
290
+ * credential for its whole window, not a single-use nonce: it is refused
291
+ * only when unknown, past its expiry, or after stop(), and every successful
292
+ * call — including a repeat within the same window — mints a fresh device
293
+ * session (see the module doc for the mobile cookie-context rationale).
258
294
  * @param token - the token secret from the QR link.
259
295
  * @param userAgent - optional User-Agent header captured at accept.
260
296
  * @returns the new device id, or a refusal code.
261
297
  */
262
298
  accept(token, userAgent) {
263
299
  const record = this.tokens.get(token);
264
- // A consumed token stays a valid bearer credential until expiry or
265
- // replacement (see the module doc): re-accept mints a fresh device.
266
300
  if (record === undefined || this.stopped || this.clock.now() > record.expiresAt) {
267
301
  return { ok: false, code: 'invalid' };
268
302
  }
269
- record.consumed = true;
270
303
  const deviceId = this.clock.randomToken();
271
304
  const now = this.clock.now();
272
305
  if (this.devices.size >= this.config.maxDevices) {
@@ -297,7 +330,7 @@ export class PairingService {
297
330
  stop() {
298
331
  this.tokens.clear();
299
332
  this.devices.clear();
300
- this.persist();
333
+ this.persistRevocation();
301
334
  this.stopped = true;
302
335
  this.notify();
303
336
  }
@@ -312,7 +345,7 @@ export class PairingService {
312
345
  return false;
313
346
  if (!this.devices.delete(deviceId))
314
347
  return false;
315
- this.persist();
348
+ this.persistRevocation();
316
349
  this.notify();
317
350
  return true;
318
351
  }
@@ -349,7 +382,9 @@ export class PairingService {
349
382
  */
350
383
  sweep() {
351
384
  const evicted = this.evictIdle();
352
- if (evicted || this.dirty)
385
+ if (evicted)
386
+ this.persistRevocation();
387
+ else if (this.dirty)
353
388
  this.persist();
354
389
  this.notify();
355
390
  }
@@ -407,7 +442,7 @@ export class PairingService {
407
442
  const limit = this.config.idleExpireMs ?? DEFAULT_IDLE_EXPIRE_MS;
408
443
  if (this.clock.now() - session.lastSeenAt > limit) {
409
444
  this.devices.delete(deviceId);
410
- this.persist();
445
+ this.persistRevocation();
411
446
  this.notify();
412
447
  return undefined;
413
448
  }
@@ -494,6 +529,20 @@ function relayEqual(a, b) {
494
529
  function sameStrings(a, b) {
495
530
  return a.length === b.length && a.every((value, index) => value === b[index]);
496
531
  }
532
+ /**
533
+ * Canonical origin of a public base URL. An unparsable value is returned
534
+ * verbatim so the caller's malformed-base warning stays the single owner of
535
+ * that case.
536
+ * @param url - the configured public base.
537
+ */
538
+ export function canonicalBaseUrl(url) {
539
+ try {
540
+ return new URL(url).origin;
541
+ }
542
+ catch {
543
+ return url;
544
+ }
545
+ }
497
546
  /** Strip control characters and cap the User-Agent stored with a session. */
498
547
  export function sanitizeUserAgent(raw) {
499
548
  if (raw === undefined)
@@ -180,4 +180,30 @@ describe('PairingService device persistence', () => {
180
180
  const deviceId = result.ok ? result.deviceId : '';
181
181
  expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone');
182
182
  });
183
+ it('operator is not left with a revocation that a restart silently undoes', () => {
184
+ // Given a device store that cannot be written (its parent path is a file)
185
+ // while a stale, readable store sits beside it.
186
+ const blocker = join(dir, 'blocker');
187
+ writeFileSync(blocker, 'not a directory');
188
+ const file = join(blocker, 'devices.json');
189
+ const removed = [];
190
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock(), { removeFile: (path) => { removed.push(path); } });
191
+ // When the operator stops remote access and the write cannot be made durable.
192
+ service.stop();
193
+ // Then the stale store is handed to the removal step, so a restart cannot
194
+ // restore the revoked sessions from disk.
195
+ expect(removed).toEqual([file]);
196
+ });
197
+ it('operator keeps the device store when a revocation persist succeeds', () => {
198
+ // Given a writable device store with one paired device.
199
+ const file = join(dir, 'devices.json');
200
+ const removed = [];
201
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock(), { removeFile: (path) => { removed.push(path); } });
202
+ const deviceId = pairDevice(service);
203
+ // When the device is revoked and the write lands.
204
+ expect(service.revoke(deviceId)).toBe(true);
205
+ // Then the store stays (with the row gone) and nothing is removed.
206
+ expect(removed).toEqual([]);
207
+ expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({});
208
+ });
183
209
  });
@@ -9,15 +9,23 @@
9
9
  *
10
10
  * The probe issues loopback requests with a forged Host header — the exact
11
11
  * shape a tunnel or LAN client produces — and treats anything other than a
12
- * 403 as exposed: the fence is documented to refuse with 403, so any other
13
- * status means the request reached the RPC bridge.
12
+ * 403 or a 401 as exposed. The fence is documented to refuse with 403; the
13
+ * harness answers 401 when the Host fence PASSED but the browser-auth cookie
14
+ * is missing (the connection plugin checks the fence first and the cookie
15
+ * second). A 401 therefore means the /api surface is still gated — reporting
16
+ * it as open produced a false CRITICAL on exactly the supported LAN
17
+ * deployment (0.0.0.0 bind auto-trusts the LAN literals) and desensitized the
18
+ * one alarm that should mean a real exposure.
14
19
  */
15
20
  import type { ClientRequest, ClientRequestArgs } from 'node:http';
16
21
  /** One probed origin. */
17
22
  export interface PostureHost {
18
23
  /** The forged Host header value that was probed. */
19
24
  host: string;
20
- /** True when the probe reached past the fence (anything but 403). */
25
+ /**
26
+ * True when the probe reached the RPC bridge without any gate refusing it
27
+ * (a status that is neither the fence's 403 nor the browser-auth 401).
28
+ */
21
29
  exposed: boolean;
22
30
  }
23
31
  /** One probe round. */
@@ -1 +1 @@
1
- {"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/posture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAGjE,yBAAyB;AACzB,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,uBAAuB;AACvB,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,KAAK,EAAE,WAAW,EAAE,CAAA;CACrB;AAED,sDAAsD;AACtD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAehH;AAED,yDAAyD;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,aAAa,CAAA;AAyC5G,qBAAqB;AACrB,MAAM,WAAW,mBAAmB;IAClC,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,qCAAqC;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,eAAe,CAAC,CAWzF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAGxG;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEpG"}
1
+ {"version":3,"file":"posture.d.ts","sourceRoot":"","sources":["../../src/posture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAA;AAGjE,yBAAyB;AACzB,MAAM,WAAW,WAAW;IAC1B,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,uBAAuB;AACvB,MAAM,WAAW,eAAe;IAC9B,2CAA2C;IAC3C,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,KAAK,EAAE,WAAW,EAAE,CAAA;CACrB;AAED,sDAAsD;AACtD,wBAAgB,UAAU,CAAC,QAAQ,EAAE,eAAe,GAAG,OAAO,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAehH;AAED,yDAAyD;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,KAAK,aAAa,CAAA;AAyC5G,qBAAqB;AACrB,MAAM,WAAW,mBAAmB;IAClC,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,8CAA8C;IAC9C,OAAO,CAAC,EAAE,YAAY,CAAA;IACtB,0CAA0C;IAC1C,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,qCAAqC;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,eAAe,CAAC,CAWzF;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAGxG;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEpG"}
@@ -9,8 +9,13 @@
9
9
  *
10
10
  * The probe issues loopback requests with a forged Host header — the exact
11
11
  * shape a tunnel or LAN client produces — and treats anything other than a
12
- * 403 as exposed: the fence is documented to refuse with 403, so any other
13
- * status means the request reached the RPC bridge.
12
+ * 403 or a 401 as exposed. The fence is documented to refuse with 403; the
13
+ * harness answers 401 when the Host fence PASSED but the browser-auth cookie
14
+ * is missing (the connection plugin checks the fence first and the cookie
15
+ * second). A 401 therefore means the /api surface is still gated — reporting
16
+ * it as open produced a false CRITICAL on exactly the supported LAN
17
+ * deployment (0.0.0.0 bind auto-trusts the LAN literals) and desensitized the
18
+ * one alarm that should mean a real exposure.
14
19
  */
15
20
  import http from 'node:http';
16
21
  /** Whether any probed origin is currently exposed. */
@@ -75,7 +80,7 @@ async function probeHost(port, hostHeader, request, timeoutMs) {
75
80
  path: '/api/session.list',
76
81
  headers: { host: hostHeader, 'content-type': 'application/json' },
77
82
  timeout: timeoutMs,
78
- }, status => { finish(status !== 403); });
83
+ }, status => { finish(status !== 403 && status !== 401); });
79
84
  const timer = setTimeout(() => { finish(false); }, timeoutMs + 1_000);
80
85
  handle.on('error', () => { finish(false); });
81
86
  handle.end('{}');
@@ -7,10 +7,14 @@
7
7
  * consult.
8
8
  *
9
9
  * Security model:
10
- * - While `requirePairingForLan` is on (default), every request must carry a
11
- * live paired-device cookie, enforced before any bytes are forwarded and
12
- * before any host call. With the policy off, the cookie gate is skipped
13
- * (the local-only denials below still apply).
10
+ * - Every request must carry a live paired-device credential, enforced before
11
+ * any bytes are forwarded and before any host call. This gate is
12
+ * unconditional and does NOT follow `requirePairingForLan`: the channel
13
+ * re-issues traffic with the process's own browser-auth credential, so it is
14
+ * an authentication amplifier — an unpaired caller admitted here would reach
15
+ * the full host API without ever holding that credential. The LAN policy
16
+ * governs the plain `/api` surface only (whether the harness fence's LAN
17
+ * auto-trust stands on its own); it can never widen this channel.
14
18
  * - A paired remote desktop is a full-control credential: the browser half
15
19
  * flips the official UI into host mode (the transport ownsHost hook), so
16
20
  * the configuration plane (settings, credentials, presets, deliverables)
@@ -31,15 +35,8 @@ export { REMOTE_API_PREFIX } from './remote-methods.ts';
31
35
  export interface RemoteApiDeps {
32
36
  /** The pairing service (device gate + cookie name). */
33
37
  service: PairingService;
34
- /** The local webServer port the loopback proxy connects to. */
38
+ /** Local webServer port the loopback proxy connects to. */
35
39
  port: number;
36
- /**
37
- * Live policy: whether the paired-device cookie gates the /remote channel.
38
- * When false, requests are proxied without a cookie (the loopback-only
39
- * denials still apply). A function is re-read per request, so a settings
40
- * edit takes effect without a restart. Defaults to true.
41
- */
42
- requirePairingForLan?: boolean | (() => boolean);
43
40
  /**
44
41
  * The process's inner browser-auth credential attached to re-issued
45
42
  * requests (the connection plugin's /api route enforces that cookie and
@@ -68,7 +65,7 @@ export declare function loopbackOnlyDenial(innerPath: string): string | undefine
68
65
  export declare function pairedDeviceIdOf(req: IncomingMessage, service: PairingService): string | undefined;
69
66
  /**
70
67
  * Build the remote desktop channel HTTP routes.
71
- * @param deps - pairing service + local port + live pairing policy.
68
+ * @param deps - pairing service + local port + inner credential.
72
69
  * @returns the routes to register on webServer.
73
70
  */
74
71
  export declare function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[];
@@ -1 +1 @@
1
- {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAA;IAChD;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CA0CnE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAWzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CA2CjF"}
1
+ {"version":3,"file":"remote-api.d.ts","sourceRoot":"","sources":["../../src/remote-api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAkB,MAAM,WAAW,CAAA;AAEhE,OAAO,KAAK,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAA;AAChF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AAGlD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAUhD,OAAO,EACL,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,aAAa,EACb,oBAAoB,EACpB,2BAA2B,EAC3B,eAAe,GAChB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAgBvD,iCAAiC;AACjC,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,cAAc,CAAA;IACvB,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAA;IACZ;;;;;OAKG;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAWD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,EAAE,OAAO,EAAE,cAAc,GAAG,MAAM,GAAG,SAAS,CAQlG;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,QAAQ,EAAE,CAwCnE;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM,CAWzF;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,EAAE,CAyCjF"}