@mycadet/gateway 0.12.9 → 0.12.10
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/package.json +1 -1
- package/src/__tests__/credential-reader.test.ts +1 -1
- package/src/__tests__/credential-watcher.test.ts +1 -1
- package/src/__tests__/remote-web-pairing-challenge.test.ts +37 -0
- package/src/cli/__tests__/pairing.test.ts +131 -0
- package/src/cli/index.ts +12 -1
- package/src/cli/pairing.ts +71 -0
- package/src/credential-reader.ts +1 -1
- package/src/http/routes/guardian-pin.ts +2 -1
- package/src/http/routes/ingress-domains.test.ts +5 -2
- package/src/http/routes/ingress-domains.ts +2 -8
- package/src/http/routes/live-voice-websocket.ts +2 -1
- package/src/http/routes/plugin-webhook-websocket.test.ts +33 -40
- package/src/http/routes/plugin-webhook-websocket.ts +2 -1
- package/src/http/routes/remote-web-pairing-challenge.ts +8 -1
- package/src/http/routes/runtime-audio-stream.ts +2 -1
- package/src/http/routes/speech-relay-websocket.ts +2 -1
- package/src/http/utils.ts +3 -0
- package/src/index.ts +4 -3
- package/src/remote-web/pairing-challenge-rate-limit-store.ts +94 -0
- package/src/remote-web/pairing-challenge-store.ts +130 -2
- package/src/remote-web/pairing-mint.ts +94 -0
package/package.json
CHANGED
|
@@ -53,7 +53,7 @@ function getMachineEntropy(): string {
|
|
|
53
53
|
} catch {
|
|
54
54
|
parts.push("unknown-user");
|
|
55
55
|
}
|
|
56
|
-
// Must match assistant/src/util/
|
|
56
|
+
// Must match assistant/src/util/os.ts#getPlatformName.
|
|
57
57
|
parts.push(process.platform);
|
|
58
58
|
parts.push(process.arch);
|
|
59
59
|
try {
|
|
@@ -56,7 +56,7 @@ function getMachineEntropy(): string {
|
|
|
56
56
|
} catch {
|
|
57
57
|
parts.push("unknown-user");
|
|
58
58
|
}
|
|
59
|
-
// Must mirror assistant/src/util/
|
|
59
|
+
// Must mirror assistant/src/util/os.ts#getPlatformName (raw platform).
|
|
60
60
|
parts.push(process.platform);
|
|
61
61
|
parts.push(process.arch);
|
|
62
62
|
try {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
4
|
|
|
3
5
|
const { handleCreateRemoteWebPairingChallenge } =
|
|
@@ -15,6 +17,7 @@ const {
|
|
|
15
17
|
} = await import("../remote-web/pairing-challenge-store.js");
|
|
16
18
|
const { resetRemoteWebPairingChallengeRateLimiterForTests } =
|
|
17
19
|
await import("../remote-web/pairing-challenge-rate-limit-store.js");
|
|
20
|
+
const { testSecurityDir } = await import("./test-preload.js");
|
|
18
21
|
|
|
19
22
|
import {
|
|
20
23
|
LOOPBACK_IP,
|
|
@@ -501,3 +504,37 @@ describe("remote web pairing request list/approve/deny", () => {
|
|
|
501
504
|
});
|
|
502
505
|
});
|
|
503
506
|
});
|
|
507
|
+
|
|
508
|
+
describe("file-backed persistence", () => {
|
|
509
|
+
test("persists challenges to the security dir for other processes", () => {
|
|
510
|
+
setRemoteWebPairingChallengeNowForTests(() => 1_000);
|
|
511
|
+
createRemoteWebPairingChallenge(PUBLIC_BASE_URL, TEST_REQUESTER);
|
|
512
|
+
|
|
513
|
+
const storePath = join(
|
|
514
|
+
testSecurityDir,
|
|
515
|
+
"remote-web-pairing-challenges.json",
|
|
516
|
+
);
|
|
517
|
+
expect(existsSync(storePath)).toBe(true);
|
|
518
|
+
const persisted: unknown[] = JSON.parse(readFileSync(storePath, "utf-8"));
|
|
519
|
+
expect(persisted).toHaveLength(1);
|
|
520
|
+
|
|
521
|
+
// The reset helper removes the file so other tests start empty.
|
|
522
|
+
resetRemoteWebPairingChallengesForTests();
|
|
523
|
+
expect(existsSync(storePath)).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("starts empty and warns when the store file is corrupt", () => {
|
|
527
|
+
const storePath = join(
|
|
528
|
+
testSecurityDir,
|
|
529
|
+
"remote-web-pairing-challenges.json",
|
|
530
|
+
);
|
|
531
|
+
writeFileSync(storePath, "{not json", "utf-8");
|
|
532
|
+
|
|
533
|
+
const challenge = createRemoteWebPairingChallenge(
|
|
534
|
+
PUBLIC_BASE_URL,
|
|
535
|
+
TEST_REQUESTER,
|
|
536
|
+
);
|
|
537
|
+
const stored = getRemoteWebPairingChallengeForTests(challenge.userCode);
|
|
538
|
+
expect(stored).toBeDefined();
|
|
539
|
+
});
|
|
540
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `gateway pairing mint <url>` CLI command. The mint executes
|
|
3
|
+
* the pairing logic DIRECTLY against the file-backed challenge store — no
|
|
4
|
+
* IPC (the assistant daemon shares the gateway's socket, so an IPC-exposed
|
|
5
|
+
* mint would let the assistant approve pairings for itself). These tests run
|
|
6
|
+
* the real command and pin both the printed challenge and its landing in
|
|
7
|
+
* the on-disk store the running gateway reads.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
const { parsePairingArgs, runPairingCommand } = await import("../pairing.js");
|
|
16
|
+
const {
|
|
17
|
+
getRemoteWebPairingChallengeForTests,
|
|
18
|
+
resetRemoteWebPairingChallengesForTests,
|
|
19
|
+
} = await import("../../remote-web/pairing-challenge-store.js");
|
|
20
|
+
const { resetRemoteWebPairingChallengeRateLimiterForTests } =
|
|
21
|
+
await import("../../remote-web/pairing-challenge-rate-limit-store.js");
|
|
22
|
+
const { testSecurityDir } = await import("../../__tests__/test-preload.js");
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Test infrastructure
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const PrintedChallengeSchema = z.object({
|
|
29
|
+
deviceCode: z.string().min(1),
|
|
30
|
+
userCode: z.string().regex(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/),
|
|
31
|
+
verificationUri: z.string(),
|
|
32
|
+
expiresAt: z.string(),
|
|
33
|
+
expiresInSeconds: z.number(),
|
|
34
|
+
intervalSeconds: z.number(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let logs: string[];
|
|
38
|
+
let errors: string[];
|
|
39
|
+
let logSpy: ReturnType<typeof spyOn>;
|
|
40
|
+
let errorSpy: ReturnType<typeof spyOn>;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
resetRemoteWebPairingChallengesForTests();
|
|
44
|
+
resetRemoteWebPairingChallengeRateLimiterForTests();
|
|
45
|
+
|
|
46
|
+
logs = [];
|
|
47
|
+
errors = [];
|
|
48
|
+
logSpy = spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
|
49
|
+
logs.push(args.map(String).join(" "));
|
|
50
|
+
});
|
|
51
|
+
errorSpy = spyOn(console, "error").mockImplementation(
|
|
52
|
+
(...args: unknown[]) => {
|
|
53
|
+
errors.push(args.map(String).join(" "));
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
logSpy.mockRestore();
|
|
60
|
+
errorSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function lastLoggedChallenge() {
|
|
64
|
+
const line = logs[logs.length - 1];
|
|
65
|
+
expect(line).toBeTruthy();
|
|
66
|
+
return PrintedChallengeSchema.parse(JSON.parse(line!));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("gateway pairing mint", () => {
|
|
70
|
+
test("mints an approved challenge directly against the file-backed store", async () => {
|
|
71
|
+
const code = await runPairingCommand([
|
|
72
|
+
"mint",
|
|
73
|
+
"https://my-assistant.ts.net",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
expect(code).toBe(0);
|
|
77
|
+
const challenge = lastLoggedChallenge();
|
|
78
|
+
expect(challenge.verificationUri).toBe(
|
|
79
|
+
"https://my-assistant.ts.net/assistant/pair",
|
|
80
|
+
);
|
|
81
|
+
expect(challenge.intervalSeconds).toBe(5);
|
|
82
|
+
|
|
83
|
+
// Approved on the spot: the CLI invocation IS the approval.
|
|
84
|
+
const stored = getRemoteWebPairingChallengeForTests(challenge.userCode);
|
|
85
|
+
expect(stored?.status).toBe("approved");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("persists the challenge to the store the running gateway reads", async () => {
|
|
89
|
+
const code = await runPairingCommand([
|
|
90
|
+
"mint",
|
|
91
|
+
"https://my-assistant.ts.net",
|
|
92
|
+
]);
|
|
93
|
+
expect(code).toBe(0);
|
|
94
|
+
|
|
95
|
+
const storePath = join(
|
|
96
|
+
testSecurityDir,
|
|
97
|
+
"remote-web-pairing-challenges.json",
|
|
98
|
+
);
|
|
99
|
+
expect(existsSync(storePath)).toBe(true);
|
|
100
|
+
const raw = readFileSync(storePath, "utf-8");
|
|
101
|
+
expect(raw).toContain('"status":"approved"');
|
|
102
|
+
expect(raw).toContain('"requesterIp":"cli"');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("rejects an unparseable public base URL", async () => {
|
|
106
|
+
const code = await runPairingCommand(["mint", "not-a-url"]);
|
|
107
|
+
|
|
108
|
+
expect(code).toBe(1);
|
|
109
|
+
expect(errors.join("\n")).toContain("publicBaseUrl is required");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("requires the public base URL argument", async () => {
|
|
113
|
+
const code = await runPairingCommand(["mint"]);
|
|
114
|
+
|
|
115
|
+
expect(code).toBe(1);
|
|
116
|
+
expect(errors.join("\n")).toContain("mint requires the public base URL");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("parsePairingArgs", () => {
|
|
121
|
+
test("splits the subcommand and positional", () => {
|
|
122
|
+
expect(parsePairingArgs(["mint", "https://x.example"])).toEqual({
|
|
123
|
+
subcommand: "mint",
|
|
124
|
+
positional: "https://x.example",
|
|
125
|
+
});
|
|
126
|
+
expect(parsePairingArgs([])).toEqual({
|
|
127
|
+
subcommand: null,
|
|
128
|
+
positional: null,
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
package/src/cli/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { runContactsCommand } from "./contacts.js";
|
|
11
|
+
import { runPairingCommand } from "./pairing.js";
|
|
11
12
|
|
|
12
13
|
function printUsage(): void {
|
|
13
14
|
console.log("Usage: gateway <command>");
|
|
@@ -15,7 +16,12 @@ function printUsage(): void {
|
|
|
15
16
|
console.log("Operator commands for gateway-owned state.");
|
|
16
17
|
console.log("");
|
|
17
18
|
console.log("Commands:");
|
|
18
|
-
console.log(
|
|
19
|
+
console.log(
|
|
20
|
+
" contacts Manage the contact ACL with list, get, set-risk-threshold",
|
|
21
|
+
);
|
|
22
|
+
console.log(
|
|
23
|
+
" pairing Mint/approve a remote-web pairing challenge (mint <url>)",
|
|
24
|
+
);
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
const args = process.argv.slice(2);
|
|
@@ -31,6 +37,11 @@ if (command === "contacts") {
|
|
|
31
37
|
process.exit(code);
|
|
32
38
|
}
|
|
33
39
|
|
|
40
|
+
if (command === "pairing") {
|
|
41
|
+
const code = await runPairingCommand(args.slice(1));
|
|
42
|
+
process.exit(code);
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
console.error(`Unknown command: ${command}`);
|
|
35
46
|
printUsage();
|
|
36
47
|
process.exit(1);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gateway pairing` — operator subcommands for the remote-web pairing flow.
|
|
3
|
+
*
|
|
4
|
+
* gateway pairing mint <url>
|
|
5
|
+
*
|
|
6
|
+
* Mints (and approves) a remote-web pairing challenge by executing the
|
|
7
|
+
* pairing mint logic DIRECTLY in this process — deliberately NOT over the
|
|
8
|
+
* gateway's IPC socket, which the assistant daemon shares (an IPC-exposed
|
|
9
|
+
* mint would let the assistant mint + approve pairings for itself). The
|
|
10
|
+
* challenge persists to the file-backed pairing store under
|
|
11
|
+
* GATEWAY_SECURITY_DIR, so the running gateway's verification and
|
|
12
|
+
* token-exchange routes see it. This is the surface `cadet pair` invokes
|
|
13
|
+
* (directly on the host, or via `docker exec` inside the gateway container):
|
|
14
|
+
* process execution is the local-presence proof, so no loopback-peer check
|
|
15
|
+
* is needed and the topology (native or containerized) stops mattering.
|
|
16
|
+
* Prints the challenge response as single-line JSON on stdout; the caller
|
|
17
|
+
* parses it and renders the pairing link/QR.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mintAndApproveRemoteWebPairingChallenge } from "../remote-web/pairing-mint.js";
|
|
21
|
+
|
|
22
|
+
function printUsage(): void {
|
|
23
|
+
console.log("Usage: gateway pairing mint <url>");
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log(
|
|
26
|
+
"Mint and approve a remote-web pairing challenge on this machine and",
|
|
27
|
+
);
|
|
28
|
+
console.log("print the challenge as JSON.");
|
|
29
|
+
console.log("");
|
|
30
|
+
console.log("Arguments:");
|
|
31
|
+
console.log(" <url> Public base URL to advertise in the pairing link,");
|
|
32
|
+
console.log(" e.g. https://your-assistant.ts.net");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parse `pairing` argv (after the command word) into a subcommand + arg. */
|
|
36
|
+
export function parsePairingArgs(args: string[]): {
|
|
37
|
+
subcommand: string | null;
|
|
38
|
+
positional: string | null;
|
|
39
|
+
} {
|
|
40
|
+
const [subcommand = null, positional = null] = args;
|
|
41
|
+
return { subcommand, positional };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runPairingCommand(args: string[]): Promise<number> {
|
|
45
|
+
const { subcommand, positional } = parsePairingArgs(args);
|
|
46
|
+
|
|
47
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
48
|
+
printUsage();
|
|
49
|
+
return subcommand ? 0 : 1;
|
|
50
|
+
}
|
|
51
|
+
if (subcommand !== "mint") {
|
|
52
|
+
console.error(`Error: unknown pairing subcommand '${subcommand}'.`);
|
|
53
|
+
printUsage();
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
if (!positional) {
|
|
57
|
+
console.error("Error: mint requires the public base URL to advertise.");
|
|
58
|
+
printUsage();
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const challenge = mintAndApproveRemoteWebPairingChallenge(positional);
|
|
64
|
+
console.log(JSON.stringify(challenge));
|
|
65
|
+
return 0;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
console.error(`Error: pairing mint failed: ${message}`);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/credential-reader.ts
CHANGED
|
@@ -46,7 +46,7 @@ type StoreFile = StoreFileV1 | StoreFileV2;
|
|
|
46
46
|
const STORE_KEY_FILENAME = "store.key";
|
|
47
47
|
|
|
48
48
|
function getPlatformName(): string {
|
|
49
|
-
// Must match assistant/src/util/
|
|
49
|
+
// Must match assistant/src/util/os.ts#getPlatformName exactly.
|
|
50
50
|
// Using user-friendly labels like "macOS" here changes PBKDF2 entropy and
|
|
51
51
|
// makes gateway unable to decrypt credentials written by the daemon.
|
|
52
52
|
return process.platform;
|
|
@@ -40,6 +40,7 @@ import { findCadetGuardian } from "../../auth/guardian-bootstrap.js";
|
|
|
40
40
|
import type { GatewayConfig } from "../../config.js";
|
|
41
41
|
import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
|
|
42
42
|
import { readStoredCloudUserId } from "../../cloud-user-id.js";
|
|
43
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
43
44
|
|
|
44
45
|
const TUNNEL_USER_ID_HEADER = "x-tunnel-user-id";
|
|
45
46
|
const TUNNEL_ORG_ID_HEADER = "x-tunnel-org-id";
|
|
@@ -175,7 +176,7 @@ export async function authorizeGuardianStream(
|
|
|
175
176
|
// Checked here as well as in the shared gate, because the cloud path
|
|
176
177
|
// below skips that gate entirely: without this, a cloud caller sending a
|
|
177
178
|
// plain request would fall through to `server.upgrade` and get a 500.
|
|
178
|
-
if (req
|
|
179
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
179
180
|
return new Response("Upgrade Required", { status: 426 });
|
|
180
181
|
}
|
|
181
182
|
|
|
@@ -97,14 +97,17 @@ describe("POST /v1/ingress/domains", () => {
|
|
|
97
97
|
expect(afterSecond).toBe(afterFirst);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
test("
|
|
100
|
+
test("does not gate on the peer IP — auth lives in the edge-guardian strategy", async () => {
|
|
101
|
+
// The docker hatch's gateway sees the host CLI as a non-loopback peer
|
|
102
|
+
// (the docker bridge IP); peer-IP gating here 403'd valid
|
|
103
|
+
// guardian-token requests, so it moved to the auth strategy.
|
|
101
104
|
const res = await handleSaveIngressDomain(
|
|
102
105
|
request({ publicBaseUrl: "https://foo.ngrok.app" }),
|
|
103
106
|
"10.0.0.1",
|
|
104
107
|
new ConfigFileCache(),
|
|
105
108
|
);
|
|
106
109
|
|
|
107
|
-
expect(res.status).toBe(
|
|
110
|
+
expect(res.status).toBe(200);
|
|
108
111
|
});
|
|
109
112
|
|
|
110
113
|
test("rejects a body without a usable public URL", async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Guardian-authed recording of a tunnel's public URL.
|
|
3
3
|
*
|
|
4
4
|
* POST /v1/ingress/domains — the CLI (its ngrok agent, today) tells the
|
|
5
5
|
* gateway the public URL its tunnel published; the gateway writes
|
|
@@ -19,11 +19,7 @@ import { normalizeHttpPublicBaseUrlWithoutTrailingSlash } from "@mycadet/service
|
|
|
19
19
|
|
|
20
20
|
import type { ConfigFileCache } from "../../config-file-cache.js";
|
|
21
21
|
import { writeManagedPublicBaseUrl } from "../../tunnel/client.js";
|
|
22
|
-
import {
|
|
23
|
-
enforceLoopbackOnly,
|
|
24
|
-
errorResponse,
|
|
25
|
-
rejectBrowserOrigin,
|
|
26
|
-
} from "../loopback-guard.js";
|
|
22
|
+
import { errorResponse, rejectBrowserOrigin } from "../loopback-guard.js";
|
|
27
23
|
|
|
28
24
|
const SaveIngressDomainRequestSchema = z.object({
|
|
29
25
|
publicBaseUrl: z.string(),
|
|
@@ -41,8 +37,6 @@ export async function handleSaveIngressDomain(
|
|
|
41
37
|
});
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
const guardError = enforceLoopbackOnly(req, clientIp, "ingress-domains");
|
|
45
|
-
if (guardError) return guardError;
|
|
46
40
|
const originError = rejectBrowserOrigin(req, clientIp, "ingress-domains");
|
|
47
41
|
if (originError) return originError;
|
|
48
42
|
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
requireBoundGuardian,
|
|
17
17
|
requireManagedGuardian,
|
|
18
18
|
} from "./guardian-pin.js";
|
|
19
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
19
20
|
|
|
20
21
|
const log = getLogger("live-voice-ws");
|
|
21
22
|
|
|
@@ -53,7 +54,7 @@ export function createLiveVoiceWebsocketHandler(config: GatewayConfig) {
|
|
|
53
54
|
req: Request,
|
|
54
55
|
server: import("bun").Server<unknown>,
|
|
55
56
|
): Promise<Response | undefined> {
|
|
56
|
-
if (req
|
|
57
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
57
58
|
return new Response("Upgrade Required", { status: 426 });
|
|
58
59
|
}
|
|
59
60
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isPluginWebhookSocketData,
|
|
14
14
|
type PluginWebhookSocketData,
|
|
15
15
|
} from "./plugin-webhook-websocket.js";
|
|
16
|
+
import { IngressRoute } from "../../channels/plugin-ingress.js";
|
|
16
17
|
|
|
17
18
|
initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long"));
|
|
18
19
|
|
|
@@ -24,7 +25,7 @@ const CONFIG = {
|
|
|
24
25
|
|
|
25
26
|
const PLUGIN_SECRET = "plugin-webhook-secret";
|
|
26
27
|
const CADET_SECRET = "platform-webhook-secret";
|
|
27
|
-
const WS_PATH = "/webhooks/plugins/
|
|
28
|
+
const WS_PATH = "/webhooks/plugins/example/realtime";
|
|
28
29
|
|
|
29
30
|
const ROUTE = {
|
|
30
31
|
path: "realtime",
|
|
@@ -43,7 +44,7 @@ function credentialsFor(entries: Record<string, string>) {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const CREDENTIALS = credentialsFor({
|
|
46
|
-
"credential/
|
|
47
|
+
"credential/example/webhook_secret": PLUGIN_SECRET,
|
|
47
48
|
"credential/cadet/webhook_secret": CADET_SECRET,
|
|
48
49
|
});
|
|
49
50
|
|
|
@@ -53,17 +54,9 @@ function resolution(
|
|
|
53
54
|
return { approved: [], pending: [], problems: [], ...overrides };
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function approvedWith(
|
|
57
|
-
routes: {
|
|
58
|
-
path: string;
|
|
59
|
-
kind: "http" | "websocket";
|
|
60
|
-
signer: "plugin" | "cadet";
|
|
61
|
-
handshake: "signed-headers" | "signed-query";
|
|
62
|
-
description: string;
|
|
63
|
-
}[],
|
|
64
|
-
): PluginIngressResolution {
|
|
57
|
+
function approvedWith(routes: IngressRoute[]): PluginIngressResolution {
|
|
65
58
|
return resolution({
|
|
66
|
-
approved: [{ plugin: "
|
|
59
|
+
approved: [{ plugin: "example", routes, digest: "d".repeat(32) }],
|
|
67
60
|
});
|
|
68
61
|
}
|
|
69
62
|
|
|
@@ -128,13 +121,13 @@ describe("upgrade gate", () => {
|
|
|
128
121
|
const res = await handle(
|
|
129
122
|
upgradeRequest(),
|
|
130
123
|
server,
|
|
131
|
-
"
|
|
124
|
+
"example",
|
|
132
125
|
"realtime",
|
|
133
126
|
);
|
|
134
127
|
|
|
135
128
|
expect(res).toBeUndefined();
|
|
136
129
|
expect(upgrades).toHaveLength(1);
|
|
137
|
-
expect(upgrades[0]!.plugin).toBe("
|
|
130
|
+
expect(upgrades[0]!.plugin).toBe("example");
|
|
138
131
|
expect(isPluginWebhookSocketData(upgrades[0])).toBe(true);
|
|
139
132
|
});
|
|
140
133
|
|
|
@@ -145,7 +138,7 @@ describe("upgrade gate", () => {
|
|
|
145
138
|
const res = await handle(
|
|
146
139
|
upgradeRequest({ upgrade: false }),
|
|
147
140
|
server,
|
|
148
|
-
"
|
|
141
|
+
"example",
|
|
149
142
|
"realtime",
|
|
150
143
|
);
|
|
151
144
|
|
|
@@ -158,7 +151,7 @@ describe("upgrade gate", () => {
|
|
|
158
151
|
resolve: () =>
|
|
159
152
|
resolution({
|
|
160
153
|
pending: [
|
|
161
|
-
{ plugin: "
|
|
154
|
+
{ plugin: "example", routes: [ROUTE], digest: "d".repeat(32) },
|
|
162
155
|
],
|
|
163
156
|
}),
|
|
164
157
|
});
|
|
@@ -167,7 +160,7 @@ describe("upgrade gate", () => {
|
|
|
167
160
|
const res = await handle(
|
|
168
161
|
upgradeRequest(),
|
|
169
162
|
server,
|
|
170
|
-
"
|
|
163
|
+
"example",
|
|
171
164
|
"realtime",
|
|
172
165
|
);
|
|
173
166
|
|
|
@@ -183,7 +176,7 @@ describe("upgrade gate", () => {
|
|
|
183
176
|
resolution({
|
|
184
177
|
pending: [
|
|
185
178
|
{
|
|
186
|
-
plugin: "
|
|
179
|
+
plugin: "example",
|
|
187
180
|
routes: [{ ...ROUTE, signer: "cadet" as const }],
|
|
188
181
|
digest: "d".repeat(32),
|
|
189
182
|
},
|
|
@@ -195,7 +188,7 @@ describe("upgrade gate", () => {
|
|
|
195
188
|
const res = await handle(
|
|
196
189
|
upgradeRequest({ secret: CADET_SECRET }),
|
|
197
190
|
server,
|
|
198
|
-
"
|
|
191
|
+
"example",
|
|
199
192
|
"realtime",
|
|
200
193
|
);
|
|
201
194
|
|
|
@@ -210,7 +203,7 @@ describe("upgrade gate", () => {
|
|
|
210
203
|
resolution({
|
|
211
204
|
pending: [
|
|
212
205
|
{
|
|
213
|
-
plugin: "
|
|
206
|
+
plugin: "example",
|
|
214
207
|
routes: [{ ...ROUTE, signer: "cadet" as const }],
|
|
215
208
|
digest: "d".repeat(32),
|
|
216
209
|
},
|
|
@@ -222,7 +215,7 @@ describe("upgrade gate", () => {
|
|
|
222
215
|
const res = await handle(
|
|
223
216
|
upgradeRequest({ secret: PLUGIN_SECRET }),
|
|
224
217
|
server,
|
|
225
|
-
"
|
|
218
|
+
"example",
|
|
226
219
|
"realtime",
|
|
227
220
|
);
|
|
228
221
|
|
|
@@ -240,7 +233,7 @@ describe("upgrade gate", () => {
|
|
|
240
233
|
const res = await handle(
|
|
241
234
|
upgradeRequest(),
|
|
242
235
|
server,
|
|
243
|
-
"
|
|
236
|
+
"example",
|
|
244
237
|
"realtime",
|
|
245
238
|
);
|
|
246
239
|
|
|
@@ -255,7 +248,7 @@ describe("upgrade gate", () => {
|
|
|
255
248
|
const res = await handle(
|
|
256
249
|
upgradeRequest({ path: "/webhooks/plugins/meeting-bot/admin" }),
|
|
257
250
|
server,
|
|
258
|
-
"
|
|
251
|
+
"example",
|
|
259
252
|
"admin",
|
|
260
253
|
);
|
|
261
254
|
|
|
@@ -274,7 +267,7 @@ describe("upgrade gate", () => {
|
|
|
274
267
|
const res = await handle(
|
|
275
268
|
upgradeRequest(),
|
|
276
269
|
server,
|
|
277
|
-
"
|
|
270
|
+
"example",
|
|
278
271
|
"realtime",
|
|
279
272
|
);
|
|
280
273
|
|
|
@@ -291,7 +284,7 @@ describe("handshake signature", () => {
|
|
|
291
284
|
const res = await handle(
|
|
292
285
|
upgradeRequest({ secret: "" }),
|
|
293
286
|
server,
|
|
294
|
-
"
|
|
287
|
+
"example",
|
|
295
288
|
"realtime",
|
|
296
289
|
);
|
|
297
290
|
|
|
@@ -306,7 +299,7 @@ describe("handshake signature", () => {
|
|
|
306
299
|
const res = await handle(
|
|
307
300
|
upgradeRequest({ secret: "nope" }),
|
|
308
301
|
server,
|
|
309
|
-
"
|
|
302
|
+
"example",
|
|
310
303
|
"realtime",
|
|
311
304
|
);
|
|
312
305
|
|
|
@@ -324,7 +317,7 @@ describe("handshake signature", () => {
|
|
|
324
317
|
timestamp: String(Math.floor(Date.now() / 1000) - 3600),
|
|
325
318
|
}),
|
|
326
319
|
server,
|
|
327
|
-
"
|
|
320
|
+
"example",
|
|
328
321
|
"realtime",
|
|
329
322
|
);
|
|
330
323
|
|
|
@@ -339,7 +332,7 @@ describe("handshake signature", () => {
|
|
|
339
332
|
const res = await handle(
|
|
340
333
|
upgradeRequest({ timestamp: "" }),
|
|
341
334
|
server,
|
|
342
|
-
"
|
|
335
|
+
"example",
|
|
343
336
|
"realtime",
|
|
344
337
|
);
|
|
345
338
|
|
|
@@ -355,7 +348,7 @@ describe("handshake signature", () => {
|
|
|
355
348
|
const res = await handle(
|
|
356
349
|
upgradeRequest({ signPath: "/webhooks/plugins/meeting-bot/other" }),
|
|
357
350
|
server,
|
|
358
|
-
"
|
|
351
|
+
"example",
|
|
359
352
|
"realtime",
|
|
360
353
|
);
|
|
361
354
|
|
|
@@ -370,7 +363,7 @@ describe("handshake signature", () => {
|
|
|
370
363
|
const res = await handle(
|
|
371
364
|
upgradeRequest(),
|
|
372
365
|
server,
|
|
373
|
-
"
|
|
366
|
+
"example",
|
|
374
367
|
"realtime",
|
|
375
368
|
);
|
|
376
369
|
|
|
@@ -388,7 +381,7 @@ describe("handshake signature", () => {
|
|
|
388
381
|
await handle(
|
|
389
382
|
upgradeRequest({ secret: CADET_SECRET }),
|
|
390
383
|
server,
|
|
391
|
-
"
|
|
384
|
+
"example",
|
|
392
385
|
"realtime",
|
|
393
386
|
),
|
|
394
387
|
).toBeUndefined();
|
|
@@ -397,7 +390,7 @@ describe("handshake signature", () => {
|
|
|
397
390
|
const rejected = await handle(
|
|
398
391
|
upgradeRequest({ secret: PLUGIN_SECRET }),
|
|
399
392
|
server,
|
|
400
|
-
"
|
|
393
|
+
"example",
|
|
401
394
|
"realtime",
|
|
402
395
|
);
|
|
403
396
|
expect(rejected?.status).toBe(403);
|
|
@@ -433,7 +426,7 @@ describe("signed-query handshake", () => {
|
|
|
433
426
|
const res = await handle(
|
|
434
427
|
signedQueryRequest(),
|
|
435
428
|
server,
|
|
436
|
-
"
|
|
429
|
+
"example",
|
|
437
430
|
"realtime",
|
|
438
431
|
);
|
|
439
432
|
|
|
@@ -454,7 +447,7 @@ describe("signed-query handshake", () => {
|
|
|
454
447
|
const res = await handle(
|
|
455
448
|
new Request(url.toString(), { headers: { upgrade: "websocket" } }),
|
|
456
449
|
server,
|
|
457
|
-
"
|
|
450
|
+
"example",
|
|
458
451
|
"realtime",
|
|
459
452
|
);
|
|
460
453
|
|
|
@@ -469,7 +462,7 @@ describe("signed-query handshake", () => {
|
|
|
469
462
|
const res = await handle(
|
|
470
463
|
signedQueryRequest({ secret: "not-the-plugin-secret" }),
|
|
471
464
|
server,
|
|
472
|
-
"
|
|
465
|
+
"example",
|
|
473
466
|
"realtime",
|
|
474
467
|
);
|
|
475
468
|
|
|
@@ -487,7 +480,7 @@ describe("signed-query handshake", () => {
|
|
|
487
480
|
const res = await handle(
|
|
488
481
|
signedQueryRequest(),
|
|
489
482
|
server,
|
|
490
|
-
"
|
|
483
|
+
"example",
|
|
491
484
|
"realtime",
|
|
492
485
|
);
|
|
493
486
|
|
|
@@ -503,7 +496,7 @@ describe("signed-query handshake", () => {
|
|
|
503
496
|
const res = await handle(
|
|
504
497
|
upgradeRequest({ secret: PLUGIN_SECRET }),
|
|
505
498
|
server,
|
|
506
|
-
"
|
|
499
|
+
"example",
|
|
507
500
|
"realtime",
|
|
508
501
|
);
|
|
509
502
|
|
|
@@ -523,7 +516,7 @@ describe("signed-query handshake", () => {
|
|
|
523
516
|
const res = await handle(
|
|
524
517
|
new Request(url.toString()),
|
|
525
518
|
server,
|
|
526
|
-
"
|
|
519
|
+
"example",
|
|
527
520
|
"realtime",
|
|
528
521
|
);
|
|
529
522
|
|
|
@@ -551,7 +544,7 @@ function socketData(
|
|
|
551
544
|
return {
|
|
552
545
|
wsType: "plugin-webhook",
|
|
553
546
|
config: CONFIG,
|
|
554
|
-
plugin: "
|
|
547
|
+
plugin: "example",
|
|
555
548
|
path: "realtime",
|
|
556
549
|
queue: [],
|
|
557
550
|
queuedBytes: 0,
|
|
@@ -591,7 +584,7 @@ describe("frame delivery", () => {
|
|
|
591
584
|
expect(calls).toHaveLength(1);
|
|
592
585
|
expect(calls[0]!.method).toBe("user_route_post");
|
|
593
586
|
expect(calls[0]!.params).toMatchObject({
|
|
594
|
-
pathParams: { path: "plugins/
|
|
587
|
+
pathParams: { path: "plugins/example/realtime" },
|
|
595
588
|
});
|
|
596
589
|
// The frame travels as the raw body, byte-for-byte.
|
|
597
590
|
expect(new TextDecoder().decode(calls[0]!.binary)).toBe(evt("joined"));
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
timestampWithinTolerance,
|
|
42
42
|
verifyCadetSignature,
|
|
43
43
|
} from "../cadet-signature.js";
|
|
44
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
44
45
|
|
|
45
46
|
const log = getLogger("plugin-webhook-ws");
|
|
46
47
|
|
|
@@ -131,7 +132,7 @@ export function createPluginWebhookWebsocketHandler(deps: PluginWebhookWsDeps) {
|
|
|
131
132
|
plugin: string,
|
|
132
133
|
path: string,
|
|
133
134
|
): Promise<Response | undefined> {
|
|
134
|
-
if (req
|
|
135
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
135
136
|
return new Response("Upgrade Required", { status: 426 });
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -21,7 +21,14 @@ import { methodNotAllowed, readJsonStringField } from "../route-helpers.js";
|
|
|
21
21
|
|
|
22
22
|
const MAX_CHALLENGE_BODY_BYTES = 512;
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Normalize an advertised base URL to `origin + trailing-slash-stripped path`,
|
|
26
|
+
* or null when the value is empty, unparseable, non-http(s), or hostless.
|
|
27
|
+
* Shared with the direct pairing mint (remote-web/pairing-mint.ts), which
|
|
28
|
+
* validates the CLI-passed URL with the exact same rules the HTTP route
|
|
29
|
+
* applies.
|
|
30
|
+
*/
|
|
31
|
+
export function parsePublicBaseUrl(value: string): string | null {
|
|
25
32
|
if (!value.trim()) {
|
|
26
33
|
return null;
|
|
27
34
|
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { admitActorToken } from "../../auth/actor-token-revocation.js";
|
|
36
36
|
import { parseSub } from "../../auth/subject.js";
|
|
37
37
|
import type { GatewayConfig } from "../../config.js";
|
|
38
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Cap on frames held while the upstream socket is still connecting, so a
|
|
@@ -83,7 +84,7 @@ export function authorizeRuntimeAudioStream(
|
|
|
83
84
|
config: GatewayConfig,
|
|
84
85
|
log: Logger,
|
|
85
86
|
): RuntimeAudioStreamAuth {
|
|
86
|
-
if (req
|
|
87
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
87
88
|
return {
|
|
88
89
|
ok: false,
|
|
89
90
|
response: new Response("Upgrade Required", { status: 426 }),
|
|
@@ -33,6 +33,7 @@ import { getLogger } from "../../logger.js";
|
|
|
33
33
|
import { getCloudBaseUrl } from "../../cloud-url.js";
|
|
34
34
|
import { TUNNEL_FORWARDED_HEADER } from "../../tunnel/bridge-utils.js";
|
|
35
35
|
import { requestHasTunnelBridgeAuth } from "../../tunnel/bridge-auth.js";
|
|
36
|
+
import { isWebsocketUpgradeRequest } from "../utils.js";
|
|
36
37
|
|
|
37
38
|
const log = getLogger("speech-relay-ws");
|
|
38
39
|
|
|
@@ -325,7 +326,7 @@ export function createSpeechRelayUpgradeHandler(
|
|
|
325
326
|
|
|
326
327
|
// Non-upgrade request: this is the daemon's dial-failure probe.
|
|
327
328
|
// Replay the gate and hand back tunnel's own rejection.
|
|
328
|
-
if (req
|
|
329
|
+
if (!isWebsocketUpgradeRequest(req)) {
|
|
329
330
|
const apiKey = await readAssistantApiKey(deps);
|
|
330
331
|
if (!apiKey) {
|
|
331
332
|
return jsonError(
|
package/src/index.ts
CHANGED
|
@@ -278,6 +278,7 @@ import {
|
|
|
278
278
|
enablePublicIngress,
|
|
279
279
|
} from "./tunnel/client.js";
|
|
280
280
|
import { VERSION_HEADER_NAME, VERSION_HEADER_VALUE } from "./version.js";
|
|
281
|
+
import { isWebsocketUpgradeRequest } from "./http/utils.js";
|
|
281
282
|
|
|
282
283
|
const log = getLogger("main");
|
|
283
284
|
|
|
@@ -889,11 +890,11 @@ async function main() {
|
|
|
889
890
|
handler: () => psHandler.handlePs(),
|
|
890
891
|
},
|
|
891
892
|
|
|
892
|
-
// ── Ingress domain recording (
|
|
893
|
+
// ── Ingress domain recording (guardian bearer, loopback fallback) ──
|
|
893
894
|
{
|
|
894
895
|
path: "/v1/ingress/domains",
|
|
895
896
|
method: "POST",
|
|
896
|
-
auth: "edge",
|
|
897
|
+
auth: "edge-guardian",
|
|
897
898
|
handler: (req, _params, getClientIp) =>
|
|
898
899
|
handleSaveIngressDomain(req, getClientIp(), configFileCache),
|
|
899
900
|
},
|
|
@@ -2309,7 +2310,7 @@ async function main() {
|
|
|
2309
2310
|
// Plugin ingress declared as `websocket`. Claimed only for a genuine
|
|
2310
2311
|
// upgrade — a plain request to the same path stays with the route table,
|
|
2311
2312
|
// where the HTTP half 404s it for not being an approved HTTP route.
|
|
2312
|
-
if (req
|
|
2313
|
+
if (isWebsocketUpgradeRequest(req)) {
|
|
2313
2314
|
const match = url.pathname.match(PLUGIN_WEBHOOK_PATH_PATTERN);
|
|
2314
2315
|
if (match) {
|
|
2315
2316
|
const upgradeResult = await handlePluginWebhookWs(
|
|
@@ -1,5 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creation-rate limit for remote-web pairing challenges — 20 per minute,
|
|
3
|
+
* FILE-BACKED under GATEWAY_SECURITY_DIR so every minting process counts
|
|
4
|
+
* against the same window: the `gateway pairing mint` CLI runs in its own
|
|
5
|
+
* process (never over the gateway's IPC socket — the assistant daemon shares
|
|
6
|
+
* that socket), and the gateway's HTTP mint route runs in the server
|
|
7
|
+
* process. A per-process window would be meaningless for the CLI (a fresh
|
|
8
|
+
* process per mint), so the timestamps persist to disk and every call
|
|
9
|
+
* re-reads them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { getGatewaySecurityDir } from "../paths.js";
|
|
22
|
+
|
|
1
23
|
const RATE_LIMIT_MAX_CHALLENGES = 20;
|
|
2
24
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
25
|
+
const STORE_FILENAME = "remote-web-pairing-creation-rate.json";
|
|
3
26
|
|
|
4
27
|
export interface RemoteWebPairingChallengeRateLimit {
|
|
5
28
|
retryAfterSeconds: number;
|
|
@@ -7,7 +30,69 @@ export interface RemoteWebPairingChallengeRateLimit {
|
|
|
7
30
|
|
|
8
31
|
const challengeCreationTimestamps: number[] = [];
|
|
9
32
|
|
|
33
|
+
let lastResolvedStorePath: string | null = null;
|
|
34
|
+
|
|
35
|
+
function rateLimitStoreFilePath(): string {
|
|
36
|
+
const resolved = join(getGatewaySecurityDir(), STORE_FILENAME);
|
|
37
|
+
lastResolvedStorePath = resolved;
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isNotExistError(err: unknown): boolean {
|
|
42
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Re-sync the timestamps from disk; a missing file is an empty window. */
|
|
46
|
+
function loadFromDisk(): void {
|
|
47
|
+
challengeCreationTimestamps.length = 0;
|
|
48
|
+
|
|
49
|
+
let raw: string;
|
|
50
|
+
try {
|
|
51
|
+
raw = readFileSync(rateLimitStoreFilePath(), "utf-8");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (isNotExistError(err)) return;
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let parsed: unknown;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
console.warn(
|
|
62
|
+
`[pairing-challenge-rate-limit] could not read ${rateLimitStoreFilePath()} — starting empty.`,
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (
|
|
67
|
+
typeof parsed !== "object" ||
|
|
68
|
+
parsed === null ||
|
|
69
|
+
!("timestamps" in parsed) ||
|
|
70
|
+
!Array.isArray(parsed.timestamps)
|
|
71
|
+
) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const timestamp of parsed.timestamps) {
|
|
75
|
+
if (typeof timestamp === "number") {
|
|
76
|
+
challengeCreationTimestamps.push(timestamp);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Persist the current window atomically so other processes see it. */
|
|
82
|
+
function saveToDisk(): void {
|
|
83
|
+
const filePath = rateLimitStoreFilePath();
|
|
84
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
85
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
86
|
+
writeFileSync(
|
|
87
|
+
tmpPath,
|
|
88
|
+
JSON.stringify({ timestamps: [...challengeCreationTimestamps] }),
|
|
89
|
+
{ mode: 0o600 },
|
|
90
|
+
);
|
|
91
|
+
renameSync(tmpPath, filePath);
|
|
92
|
+
}
|
|
93
|
+
|
|
10
94
|
export function recordRemoteWebPairingChallengeCreation(): RemoteWebPairingChallengeRateLimit | null {
|
|
95
|
+
loadFromDisk();
|
|
11
96
|
const now = Date.now();
|
|
12
97
|
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
13
98
|
|
|
@@ -26,9 +111,18 @@ export function recordRemoteWebPairingChallengeCreation(): RemoteWebPairingChall
|
|
|
26
111
|
}
|
|
27
112
|
|
|
28
113
|
challengeCreationTimestamps.push(now);
|
|
114
|
+
saveToDisk();
|
|
29
115
|
return null;
|
|
30
116
|
}
|
|
31
117
|
|
|
32
118
|
export function resetRemoteWebPairingChallengeRateLimiterForTests(): void {
|
|
33
119
|
challengeCreationTimestamps.length = 0;
|
|
120
|
+
// Best effort on the LAST-RESOLVED path — see the challenge store's note.
|
|
121
|
+
if (lastResolvedStorePath) {
|
|
122
|
+
try {
|
|
123
|
+
rmSync(lastResolvedStorePath, { force: true });
|
|
124
|
+
} catch {
|
|
125
|
+
/* the timestamps are cleared either way */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
34
128
|
}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { getGatewaySecurityDir } from "../paths.js";
|
|
2
12
|
|
|
3
13
|
import {
|
|
4
14
|
REMOTE_WEB_PAIRING_CODE_TTL_MS,
|
|
@@ -70,6 +80,94 @@ function nowMs(): number {
|
|
|
70
80
|
return nowForTests?.() ?? Date.now();
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
/**
|
|
84
|
+
* The challenge store is FILE-BACKED under GATEWAY_SECURITY_DIR so a mint can
|
|
85
|
+
* run in ANY process: `gateway pairing mint` executes the mint logic directly
|
|
86
|
+
* (deliberately never over the gateway's IPC socket — the assistant daemon
|
|
87
|
+
* shares that socket, and an IPC-exposed mint would let the assistant
|
|
88
|
+
* approve pairings for itself) and persists the challenge here. The running
|
|
89
|
+
* gateway's verification and token-exchange routes re-read the file on every
|
|
90
|
+
* store call, so cross-process writes land. Writes are atomic (tmp + rename)
|
|
91
|
+
* and the single-user local flow makes last-writer-wins races a non-issue.
|
|
92
|
+
*/
|
|
93
|
+
const STORE_FILENAME = "remote-web-pairing-challenges.json";
|
|
94
|
+
|
|
95
|
+
let lastResolvedStorePath: string | null = null;
|
|
96
|
+
|
|
97
|
+
function challengeStoreFilePath(): string {
|
|
98
|
+
const resolved = join(getGatewaySecurityDir(), STORE_FILENAME);
|
|
99
|
+
lastResolvedStorePath = resolved;
|
|
100
|
+
return resolved;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isNotExistError(err: unknown): boolean {
|
|
104
|
+
return err instanceof Error && "code" in err && err.code === "ENOENT";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isChallengeLike(
|
|
108
|
+
value: unknown,
|
|
109
|
+
): value is PendingRemoteWebPairingChallenge {
|
|
110
|
+
return (
|
|
111
|
+
typeof value === "object" &&
|
|
112
|
+
value !== null &&
|
|
113
|
+
"userCodeHash" in value &&
|
|
114
|
+
typeof value.userCodeHash === "string" &&
|
|
115
|
+
"deviceCodeHash" in value &&
|
|
116
|
+
typeof value.deviceCodeHash === "string"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Re-sync the in-memory maps from disk at the top of every exported call.
|
|
122
|
+
* A missing file is an empty store; a corrupt one warns and starts empty.
|
|
123
|
+
* Expired challenges are NOT dropped here — the same cleanup/expiry checks
|
|
124
|
+
* that ran before file-backing still classify them ("expired", not
|
|
125
|
+
* "invalid") and delete them on their own paths.
|
|
126
|
+
*/
|
|
127
|
+
function loadFromDisk(): void {
|
|
128
|
+
challengesByUserCodeHash.clear();
|
|
129
|
+
challengesByDeviceCodeHash.clear();
|
|
130
|
+
|
|
131
|
+
let raw: string;
|
|
132
|
+
try {
|
|
133
|
+
raw = readFileSync(challengeStoreFilePath(), "utf-8");
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (isNotExistError(err)) return;
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let parsed: unknown;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
} catch {
|
|
143
|
+
parsed = null;
|
|
144
|
+
}
|
|
145
|
+
if (!Array.isArray(parsed)) {
|
|
146
|
+
console.warn(
|
|
147
|
+
`[pairing-challenge-store] could not read ${challengeStoreFilePath()} — starting empty.`,
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
for (const entry of parsed) {
|
|
152
|
+
if (!isChallengeLike(entry)) continue;
|
|
153
|
+
challengesByUserCodeHash.set(entry.userCodeHash, entry);
|
|
154
|
+
challengesByDeviceCodeHash.set(entry.deviceCodeHash, entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Persist the current challenges so other processes can see them. */
|
|
159
|
+
function saveToDisk(): void {
|
|
160
|
+
const filePath = challengeStoreFilePath();
|
|
161
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
162
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
163
|
+
writeFileSync(
|
|
164
|
+
tmpPath,
|
|
165
|
+
JSON.stringify([...challengesByUserCodeHash.values()]),
|
|
166
|
+
{ mode: 0o600 },
|
|
167
|
+
);
|
|
168
|
+
renameSync(tmpPath, filePath);
|
|
169
|
+
}
|
|
170
|
+
|
|
73
171
|
function hashSecret(value: string): string {
|
|
74
172
|
return createHash("sha256").update(value).digest("hex");
|
|
75
173
|
}
|
|
@@ -100,6 +198,7 @@ function cleanupExpiredChallenges(now = nowMs()): void {
|
|
|
100
198
|
}
|
|
101
199
|
|
|
102
200
|
export function checkRemoteWebPairingChallengeCapacity(): RemoteWebPairingChallengeCapacityLimit | null {
|
|
201
|
+
loadFromDisk();
|
|
103
202
|
const now = nowMs();
|
|
104
203
|
cleanupExpiredChallenges(now);
|
|
105
204
|
|
|
@@ -122,6 +221,7 @@ export function createRemoteWebPairingChallenge(
|
|
|
122
221
|
publicBaseUrl: string,
|
|
123
222
|
requester: { ip: string; userAgent: string | null; viaEdgeProxy: boolean },
|
|
124
223
|
): RemoteWebPairingChallengeResponse {
|
|
224
|
+
loadFromDisk();
|
|
125
225
|
cleanupExpiredChallenges();
|
|
126
226
|
|
|
127
227
|
const deviceCode = randomBytes(DEVICE_CODE_BYTES).toString("base64url");
|
|
@@ -152,6 +252,7 @@ export function createRemoteWebPairingChallenge(
|
|
|
152
252
|
};
|
|
153
253
|
challengesByUserCodeHash.set(userCodeHash, challenge);
|
|
154
254
|
challengesByDeviceCodeHash.set(deviceCodeHash, challenge);
|
|
255
|
+
saveToDisk();
|
|
155
256
|
|
|
156
257
|
return {
|
|
157
258
|
deviceCode,
|
|
@@ -188,12 +289,15 @@ function approveChallenge(
|
|
|
188
289
|
export function approveRemoteWebPairingChallenge(
|
|
189
290
|
userCode: string,
|
|
190
291
|
): ApproveRemoteWebPairingChallengeResult {
|
|
292
|
+
loadFromDisk();
|
|
191
293
|
const userCodeHash = hashSecret(normalizeUserCode(userCode));
|
|
192
294
|
const challenge = challengesByUserCodeHash.get(userCodeHash);
|
|
193
295
|
if (!challenge) {
|
|
194
296
|
return { status: "invalid" };
|
|
195
297
|
}
|
|
196
|
-
|
|
298
|
+
const result = approveChallenge(challenge);
|
|
299
|
+
saveToDisk();
|
|
300
|
+
return result;
|
|
197
301
|
}
|
|
198
302
|
|
|
199
303
|
function findChallengeById(
|
|
@@ -208,6 +312,7 @@ function findChallengeById(
|
|
|
208
312
|
}
|
|
209
313
|
|
|
210
314
|
export function listPendingRemoteWebPairingChallenges(): RemoteWebPairingRequestSummary[] {
|
|
315
|
+
loadFromDisk();
|
|
211
316
|
cleanupExpiredChallenges();
|
|
212
317
|
|
|
213
318
|
const pending = [...challengesByUserCodeHash.values()]
|
|
@@ -229,11 +334,14 @@ export function listPendingRemoteWebPairingChallenges(): RemoteWebPairingRequest
|
|
|
229
334
|
export function approveRemoteWebPairingChallengeById(
|
|
230
335
|
id: string,
|
|
231
336
|
): ApproveRemoteWebPairingChallengeResult {
|
|
337
|
+
loadFromDisk();
|
|
232
338
|
const challenge = findChallengeById(id);
|
|
233
339
|
if (!challenge) {
|
|
234
340
|
return { status: "invalid" };
|
|
235
341
|
}
|
|
236
|
-
|
|
342
|
+
const result = approveChallenge(challenge);
|
|
343
|
+
saveToDisk();
|
|
344
|
+
return result;
|
|
237
345
|
}
|
|
238
346
|
|
|
239
347
|
export type DenyRemoteWebPairingChallengeResult =
|
|
@@ -244,6 +352,7 @@ export type DenyRemoteWebPairingChallengeResult =
|
|
|
244
352
|
export function denyRemoteWebPairingChallengeById(
|
|
245
353
|
id: string,
|
|
246
354
|
): DenyRemoteWebPairingChallengeResult {
|
|
355
|
+
loadFromDisk();
|
|
247
356
|
const challenge = findChallengeById(id);
|
|
248
357
|
if (!challenge) {
|
|
249
358
|
return { status: "invalid" };
|
|
@@ -256,12 +365,14 @@ export function denyRemoteWebPairingChallengeById(
|
|
|
256
365
|
}
|
|
257
366
|
|
|
258
367
|
deleteChallenge(challenge);
|
|
368
|
+
saveToDisk();
|
|
259
369
|
return { status: "denied" };
|
|
260
370
|
}
|
|
261
371
|
|
|
262
372
|
export function claimRemoteWebPairingChallengeExchange(
|
|
263
373
|
deviceCode: string,
|
|
264
374
|
): ClaimRemoteWebPairingChallengeExchangeResult {
|
|
375
|
+
loadFromDisk();
|
|
265
376
|
const deviceCodeHash = hashSecret(deviceCode);
|
|
266
377
|
const challenge = challengesByDeviceCodeHash.get(deviceCodeHash);
|
|
267
378
|
if (!challenge) return { status: "invalid" };
|
|
@@ -269,6 +380,7 @@ export function claimRemoteWebPairingChallengeExchange(
|
|
|
269
380
|
const now = nowMs();
|
|
270
381
|
if (challenge.expiresAtMs <= now) {
|
|
271
382
|
deleteChallenge(challenge);
|
|
383
|
+
saveToDisk();
|
|
272
384
|
return { status: "expired" };
|
|
273
385
|
}
|
|
274
386
|
|
|
@@ -290,6 +402,7 @@ export function claimRemoteWebPairingChallengeExchange(
|
|
|
290
402
|
|
|
291
403
|
challenge.status = "exchanging";
|
|
292
404
|
challenge.exchangeStartedAtMs = now;
|
|
405
|
+
saveToDisk();
|
|
293
406
|
return {
|
|
294
407
|
status: "approved",
|
|
295
408
|
publicBaseUrl: challenge.publicBaseUrl,
|
|
@@ -301,27 +414,41 @@ export function claimRemoteWebPairingChallengeExchange(
|
|
|
301
414
|
export function completeRemoteWebPairingChallengeExchange(
|
|
302
415
|
deviceCode: string,
|
|
303
416
|
): void {
|
|
417
|
+
loadFromDisk();
|
|
304
418
|
const challenge = challengesByDeviceCodeHash.get(hashSecret(deviceCode));
|
|
305
419
|
if (!challenge || challenge.status !== "exchanging") return;
|
|
306
420
|
|
|
307
421
|
challenge.status = "consumed";
|
|
308
422
|
challenge.consumedAtMs = nowMs();
|
|
423
|
+
saveToDisk();
|
|
309
424
|
}
|
|
310
425
|
|
|
311
426
|
export function releaseRemoteWebPairingChallengeExchange(
|
|
312
427
|
deviceCode: string,
|
|
313
428
|
): void {
|
|
429
|
+
loadFromDisk();
|
|
314
430
|
const challenge = challengesByDeviceCodeHash.get(hashSecret(deviceCode));
|
|
315
431
|
if (!challenge || challenge.status !== "exchanging") return;
|
|
316
432
|
|
|
317
433
|
challenge.status = "approved";
|
|
318
434
|
challenge.exchangeStartedAtMs = undefined;
|
|
435
|
+
saveToDisk();
|
|
319
436
|
}
|
|
320
437
|
|
|
321
438
|
export function resetRemoteWebPairingChallengesForTests(): void {
|
|
322
439
|
challengesByUserCodeHash.clear();
|
|
323
440
|
challengesByDeviceCodeHash.clear();
|
|
324
441
|
nowForTests = null;
|
|
442
|
+
// Best effort on the LAST-RESOLVED path: test files may delete
|
|
443
|
+
// GATEWAY_SECURITY_DIR before this runs, and re-resolving then would
|
|
444
|
+
// either throw the ephemeral-path guard or point at the wrong dir.
|
|
445
|
+
if (lastResolvedStorePath) {
|
|
446
|
+
try {
|
|
447
|
+
rmSync(lastResolvedStorePath, { force: true });
|
|
448
|
+
} catch {
|
|
449
|
+
/* the maps are cleared either way */
|
|
450
|
+
}
|
|
451
|
+
}
|
|
325
452
|
}
|
|
326
453
|
|
|
327
454
|
export function setRemoteWebPairingChallengeNowForTests(
|
|
@@ -333,5 +460,6 @@ export function setRemoteWebPairingChallengeNowForTests(
|
|
|
333
460
|
export function getRemoteWebPairingChallengeForTests(
|
|
334
461
|
userCode: string,
|
|
335
462
|
): PendingRemoteWebPairingChallenge | undefined {
|
|
463
|
+
loadFromDisk();
|
|
336
464
|
return challengesByUserCodeHash.get(hashSecret(normalizeUserCode(userCode)));
|
|
337
465
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint-and-approve for the remote-web pairing flow — executed DIRECTLY in
|
|
3
|
+
* the calling process (the `gateway pairing mint` CLI), deliberately NOT
|
|
4
|
+
* exposed over the gateway's IPC socket: the assistant daemon shares that
|
|
5
|
+
* socket, and an IPC-exposed mint would let the assistant mint + approve
|
|
6
|
+
* pairings for itself, defeating the local-presence proof. Challenges
|
|
7
|
+
* persist to the file-backed pairing store under GATEWAY_SECURITY_DIR, so
|
|
8
|
+
* the RUNNING gateway's verification and token-exchange routes see what
|
|
9
|
+
* this mints.
|
|
10
|
+
*
|
|
11
|
+
* This is the exact sequence the loopback HTTP mint used to run (parse →
|
|
12
|
+
* rate-limit → capacity → create → approve), with the requester metadata
|
|
13
|
+
* honestly naming the CLI invocation instead of an HTTP peer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { RemoteWebPairingChallengeResponse } from "@mycadet/service-contracts/remote-web-pairing";
|
|
17
|
+
|
|
18
|
+
import { parsePublicBaseUrl } from "../http/routes/remote-web-pairing-challenge.js";
|
|
19
|
+
import { recordRemoteWebPairingChallengeCreation } from "./pairing-challenge-rate-limit-store.js";
|
|
20
|
+
import {
|
|
21
|
+
approveRemoteWebPairingChallenge,
|
|
22
|
+
checkRemoteWebPairingChallengeCapacity,
|
|
23
|
+
createRemoteWebPairingChallenge,
|
|
24
|
+
type RemoteWebPairingChallengeCapacityLimit,
|
|
25
|
+
} from "./pairing-challenge-store.js";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Client-facing failure with a stable code (BAD_REQUEST, RATE_LIMITED,
|
|
29
|
+
* PAIRING_CHALLENGE_CAPACITY_EXCEEDED, MINT_APPROVAL_FAILED).
|
|
30
|
+
*/
|
|
31
|
+
export class PairingMintError extends Error {
|
|
32
|
+
readonly code: string;
|
|
33
|
+
|
|
34
|
+
constructor(message: string, code: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "PairingMintError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rateLimitError(retryAfterSeconds: number): PairingMintError {
|
|
42
|
+
return new PairingMintError(
|
|
43
|
+
`too many remote web pairing challenges; retry in ${retryAfterSeconds}s`,
|
|
44
|
+
"RATE_LIMITED",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capacityLimitError(
|
|
49
|
+
limit: RemoteWebPairingChallengeCapacityLimit,
|
|
50
|
+
): PairingMintError {
|
|
51
|
+
return new PairingMintError(
|
|
52
|
+
`too many pending remote web pairing challenges; retry in ${limit.retryAfterSeconds}s`,
|
|
53
|
+
"PAIRING_CHALLENGE_CAPACITY_EXCEEDED",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function mintAndApproveRemoteWebPairingChallenge(
|
|
58
|
+
publicBaseUrlRaw: string,
|
|
59
|
+
): RemoteWebPairingChallengeResponse {
|
|
60
|
+
const publicBaseUrl = parsePublicBaseUrl(publicBaseUrlRaw);
|
|
61
|
+
if (!publicBaseUrl) {
|
|
62
|
+
throw new PairingMintError(
|
|
63
|
+
"publicBaseUrl is required (an http(s) URL with a host)",
|
|
64
|
+
"BAD_REQUEST",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rateLimited = recordRemoteWebPairingChallengeCreation();
|
|
69
|
+
if (rateLimited) throw rateLimitError(rateLimited.retryAfterSeconds);
|
|
70
|
+
|
|
71
|
+
const capacityLimited = checkRemoteWebPairingChallengeCapacity();
|
|
72
|
+
if (capacityLimited) throw capacityLimitError(capacityLimited);
|
|
73
|
+
|
|
74
|
+
// The requester fields back the host approval UI; a direct mint has no
|
|
75
|
+
// HTTP peer to report, so the honest provenance is the CLI invocation
|
|
76
|
+
// itself.
|
|
77
|
+
const challenge = createRemoteWebPairingChallenge(publicBaseUrl, {
|
|
78
|
+
ip: "cli",
|
|
79
|
+
userAgent: null,
|
|
80
|
+
viaEdgeProxy: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const approved = approveRemoteWebPairingChallenge(challenge.userCode);
|
|
84
|
+
if (approved.status !== "approved") {
|
|
85
|
+
// Unreachable for a challenge minted this tick, but fail loudly rather
|
|
86
|
+
// than hand out an unapproved link.
|
|
87
|
+
throw new PairingMintError(
|
|
88
|
+
`minted challenge could not be approved (${approved.status})`,
|
|
89
|
+
"MINT_APPROVAL_FAILED",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return challenge;
|
|
94
|
+
}
|