@agent-native/dispatch 0.15.29 → 0.16.2
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/actions/create-browser-chat-session.d.ts +15 -0
- package/dist/actions/create-browser-chat-session.d.ts.map +1 -0
- package/dist/actions/create-browser-chat-session.js +75 -0
- package/dist/actions/create-browser-chat-session.js.map +1 -0
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +14 -14
- package/dist/actions/update-workspace-resource.d.ts +13 -13
- package/dist/actions/upsert-destination.d.ts +12 -12
- package/dist/actions/view-screen.js +4 -2
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/create-app-popover.d.ts.map +1 -1
- package/dist/components/create-app-popover.js +7 -0
- package/dist/components/create-app-popover.js.map +1 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -4
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks/use-navigation-state.js +2 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/lib/automations.d.ts.map +1 -1
- package/dist/lib/automations.js +5 -4
- package/dist/lib/automations.js.map +1 -1
- package/dist/lib/browser-chat-bridge.d.ts +8 -0
- package/dist/lib/browser-chat-bridge.d.ts.map +1 -0
- package/dist/lib/browser-chat-bridge.js +48 -0
- package/dist/lib/browser-chat-bridge.js.map +1 -0
- package/dist/lib/browser-chat-protocol.d.ts +33 -0
- package/dist/lib/browser-chat-protocol.d.ts.map +1 -0
- package/dist/lib/browser-chat-protocol.js +82 -0
- package/dist/lib/browser-chat-protocol.js.map +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/browser-chat.d.ts +5 -0
- package/dist/routes/pages/browser-chat.d.ts.map +1 -0
- package/dist/routes/pages/browser-chat.js +43 -0
- package/dist/routes/pages/browser-chat.js.map +1 -0
- package/dist/routes/pages/browser-connect.d.ts +5 -0
- package/dist/routes/pages/browser-connect.d.ts.map +1 -0
- package/dist/routes/pages/browser-connect.js +72 -0
- package/dist/routes/pages/browser-connect.js.map +1 -0
- package/dist/server/lib/browser-extension-allowlist.d.ts +9 -0
- package/dist/server/lib/browser-extension-allowlist.d.ts.map +1 -0
- package/dist/server/lib/browser-extension-allowlist.js +39 -0
- package/dist/server/lib/browser-extension-allowlist.js.map +1 -0
- package/package.json +3 -3
- package/src/actions/create-browser-chat-session.spec.ts +122 -0
- package/src/actions/create-browser-chat-session.ts +99 -0
- package/src/actions/index.ts +2 -0
- package/src/actions/view-screen.ts +5 -2
- package/src/components/create-app-popover.spec.tsx +53 -0
- package/src/components/create-app-popover.tsx +49 -0
- package/src/components/layout/Layout.spec.tsx +0 -1
- package/src/components/layout/Layout.tsx +2 -6
- package/src/config.ts +5 -0
- package/src/hooks/use-navigation-state.spec.ts +7 -0
- package/src/hooks/use-navigation-state.ts +1 -0
- package/src/lib/automations.spec.ts +64 -0
- package/src/lib/automations.ts +5 -3
- package/src/lib/browser-chat-bridge.spec.ts +151 -0
- package/src/lib/browser-chat-bridge.ts +79 -0
- package/src/lib/browser-chat-protocol.spec.ts +113 -0
- package/src/lib/browser-chat-protocol.ts +124 -0
- package/src/routes/index.spec.ts +6 -0
- package/src/routes/index.ts +2 -0
- package/src/routes/pages/browser-chat.spec.tsx +121 -0
- package/src/routes/pages/browser-chat.tsx +89 -0
- package/src/routes/pages/browser-connect.spec.tsx +137 -0
- package/src/routes/pages/browser-connect.tsx +143 -0
- package/src/server/lib/browser-extension-allowlist.spec.ts +76 -0
- package/src/server/lib/browser-extension-allowlist.ts +58 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { MemoryRouter } from "react-router";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import BrowserConnectRoute from "./browser-connect.js";
|
|
8
|
+
|
|
9
|
+
const browserConnect = vi.hoisted(() => ({
|
|
10
|
+
mutateAsync: vi.fn(),
|
|
11
|
+
sendMessage: vi.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@agent-native/core/client/hooks", () => ({
|
|
15
|
+
useActionMutation: () => ({
|
|
16
|
+
mutateAsync: browserConnect.mutateAsync,
|
|
17
|
+
isPending: false,
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("@agent-native/core/client/i18n", () => ({
|
|
22
|
+
useT: () => (key: string) =>
|
|
23
|
+
({
|
|
24
|
+
"dispatch.pages.browserConnectTitle": "Connect browser chat",
|
|
25
|
+
"dispatch.pages.browserConnectDescription": "Description",
|
|
26
|
+
"dispatch.pages.browserConnectInvalid": "Invalid request",
|
|
27
|
+
"dispatch.pages.browserConnectConnected": "Connected",
|
|
28
|
+
"dispatch.pages.browserConnectConnecting": "Connecting",
|
|
29
|
+
"dispatch.pages.browserConnectButton": "Connect",
|
|
30
|
+
"dispatch.pages.browserConnectOpenFromExtension": "Open from extension",
|
|
31
|
+
"dispatch.pages.browserConnectFailed": "Connection failed",
|
|
32
|
+
})[key] ?? key,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const extensionId = "abcdefghijklmnopabcdefghijklmnop";
|
|
36
|
+
const nonce = "browser-chat-nonce-1234567890";
|
|
37
|
+
|
|
38
|
+
describe("BrowserConnectRoute", () => {
|
|
39
|
+
let container: HTMLDivElement;
|
|
40
|
+
let root: Root;
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
44
|
+
browserConnect.mutateAsync.mockReset();
|
|
45
|
+
browserConnect.sendMessage.mockReset();
|
|
46
|
+
browserConnect.sendMessage.mockImplementation(
|
|
47
|
+
(
|
|
48
|
+
_extensionId: string,
|
|
49
|
+
_message: Record<string, unknown>,
|
|
50
|
+
callback: (response: { ok: boolean }) => void,
|
|
51
|
+
) => callback({ ok: true }),
|
|
52
|
+
);
|
|
53
|
+
vi.stubGlobal("chrome", {
|
|
54
|
+
runtime: {
|
|
55
|
+
sendMessage: browserConnect.sendMessage,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
container = document.createElement("div");
|
|
59
|
+
document.body.appendChild(container);
|
|
60
|
+
root = createRoot(container);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
act(() => root.unmount());
|
|
65
|
+
container.remove();
|
|
66
|
+
vi.unstubAllGlobals();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("requires an explicit click before sending a one-time session to the exact extension", async () => {
|
|
70
|
+
browserConnect.mutateAsync.mockResolvedValue({
|
|
71
|
+
startPath: "/_agent-native/embed/start?ticket=one-time-ticket",
|
|
72
|
+
expiresAt: 1_900_000_000_000,
|
|
73
|
+
parentOrigin: `chrome-extension://${extensionId}`,
|
|
74
|
+
remoteDevice: {
|
|
75
|
+
id: "remote-device-example",
|
|
76
|
+
token: "anr_example",
|
|
77
|
+
},
|
|
78
|
+
relayBaseUrl: "https://dispatch.example.com",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await act(async () => {
|
|
82
|
+
root.render(
|
|
83
|
+
<MemoryRouter
|
|
84
|
+
initialEntries={[
|
|
85
|
+
`/browser-connect?extensionId=${extensionId}&nonce=${nonce}`,
|
|
86
|
+
]}
|
|
87
|
+
>
|
|
88
|
+
<BrowserConnectRoute />
|
|
89
|
+
</MemoryRouter>,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
expect(browserConnect.mutateAsync).not.toHaveBeenCalled();
|
|
93
|
+
|
|
94
|
+
await act(async () => {
|
|
95
|
+
container.querySelector("button")?.click();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(browserConnect.mutateAsync).toHaveBeenCalledWith({
|
|
99
|
+
extensionId,
|
|
100
|
+
nonce,
|
|
101
|
+
});
|
|
102
|
+
expect(browserConnect.sendMessage).toHaveBeenCalledWith(
|
|
103
|
+
extensionId,
|
|
104
|
+
{
|
|
105
|
+
type: "browser-chat.session.v1",
|
|
106
|
+
nonce,
|
|
107
|
+
startPath: "/_agent-native/embed/start?ticket=one-time-ticket",
|
|
108
|
+
dispatchOrigin: window.location.origin,
|
|
109
|
+
expiresAt: "2030-03-17T17:46:40.000Z",
|
|
110
|
+
remoteDevice: {
|
|
111
|
+
id: "remote-device-example",
|
|
112
|
+
token: "anr_example",
|
|
113
|
+
},
|
|
114
|
+
relayBaseUrl: "https://dispatch.example.com",
|
|
115
|
+
},
|
|
116
|
+
expect.any(Function),
|
|
117
|
+
);
|
|
118
|
+
expect(container.textContent).toContain("Connected");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("fails closed on malformed connection parameters", async () => {
|
|
122
|
+
await act(async () => {
|
|
123
|
+
root.render(
|
|
124
|
+
<MemoryRouter
|
|
125
|
+
initialEntries={[
|
|
126
|
+
"/browser-connect?extensionId=wildcard&nonce=too-short",
|
|
127
|
+
]}
|
|
128
|
+
>
|
|
129
|
+
<BrowserConnectRoute />
|
|
130
|
+
</MemoryRouter>,
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(container.textContent).toContain("Invalid request");
|
|
135
|
+
expect(container.querySelector("button")).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useActionMutation } from "@agent-native/core/client/hooks";
|
|
2
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
3
|
+
import { IconBrowserCheck, IconPlugConnected } from "@tabler/icons-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { useSearchParams } from "react-router";
|
|
6
|
+
|
|
7
|
+
import { Button } from "../../components/ui/button.js";
|
|
8
|
+
import {
|
|
9
|
+
browserChatExtensionIdSchema,
|
|
10
|
+
browserChatNonceSchema,
|
|
11
|
+
} from "../../lib/browser-chat-protocol.js";
|
|
12
|
+
|
|
13
|
+
interface BrowserChatSessionResult {
|
|
14
|
+
startPath: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
parentOrigin: string;
|
|
17
|
+
remoteDevice: {
|
|
18
|
+
id: string;
|
|
19
|
+
token: string;
|
|
20
|
+
};
|
|
21
|
+
relayBaseUrl: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ChromeRuntimeApi {
|
|
25
|
+
lastError?: { message?: string };
|
|
26
|
+
sendMessage(
|
|
27
|
+
extensionId: string,
|
|
28
|
+
message: Record<string, unknown>,
|
|
29
|
+
callback: (response?: { ok?: boolean; error?: string }) => void,
|
|
30
|
+
): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sendBrowserChatSession(
|
|
34
|
+
extensionId: string,
|
|
35
|
+
message: Record<string, unknown>,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
const runtime = (
|
|
38
|
+
globalThis as typeof globalThis & {
|
|
39
|
+
chrome?: { runtime?: ChromeRuntimeApi };
|
|
40
|
+
}
|
|
41
|
+
).chrome?.runtime;
|
|
42
|
+
if (!runtime) {
|
|
43
|
+
return Promise.reject(new Error("extension-unavailable"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
runtime.sendMessage(extensionId, message, (response) => {
|
|
48
|
+
const runtimeError = runtime.lastError?.message;
|
|
49
|
+
if (runtimeError) {
|
|
50
|
+
reject(new Error(runtimeError));
|
|
51
|
+
} else if (response?.ok !== true) {
|
|
52
|
+
reject(new Error("extension-rejected"));
|
|
53
|
+
} else {
|
|
54
|
+
resolve();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function meta() {
|
|
61
|
+
return [{ title: "Dispatch" }];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default function BrowserConnectRoute() {
|
|
65
|
+
const t = useT();
|
|
66
|
+
const [searchParams] = useSearchParams();
|
|
67
|
+
const extensionId = searchParams.get("extensionId") ?? "";
|
|
68
|
+
const parsedExtensionId = browserChatExtensionIdSchema.safeParse(extensionId);
|
|
69
|
+
const nonce = browserChatNonceSchema.safeParse(searchParams.get("nonce"));
|
|
70
|
+
const requestIsValid = parsedExtensionId.success && nonce.success;
|
|
71
|
+
const [connected, setConnected] = useState(false);
|
|
72
|
+
const [error, setError] = useState<string | null>(null);
|
|
73
|
+
const createSession = useActionMutation<
|
|
74
|
+
BrowserChatSessionResult,
|
|
75
|
+
{ extensionId: string; nonce: string }
|
|
76
|
+
>("create-browser-chat-session", {});
|
|
77
|
+
|
|
78
|
+
const connect = async () => {
|
|
79
|
+
if (!requestIsValid) return;
|
|
80
|
+
setError(null);
|
|
81
|
+
try {
|
|
82
|
+
const session = await createSession.mutateAsync({
|
|
83
|
+
extensionId,
|
|
84
|
+
nonce: nonce.data,
|
|
85
|
+
});
|
|
86
|
+
await sendBrowserChatSession(extensionId, {
|
|
87
|
+
type: "browser-chat.session.v1",
|
|
88
|
+
nonce: nonce.data,
|
|
89
|
+
startPath: session.startPath,
|
|
90
|
+
dispatchOrigin: window.location.origin,
|
|
91
|
+
expiresAt: new Date(session.expiresAt).toISOString(),
|
|
92
|
+
remoteDevice: session.remoteDevice,
|
|
93
|
+
relayBaseUrl: session.relayBaseUrl,
|
|
94
|
+
});
|
|
95
|
+
setConnected(true);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
setError(
|
|
98
|
+
cause instanceof Error && cause.message === "extension-unavailable"
|
|
99
|
+
? t("dispatch.pages.browserConnectOpenFromExtension")
|
|
100
|
+
: t("dispatch.pages.browserConnectFailed"),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<main className="grid min-h-screen place-items-center bg-background p-6">
|
|
107
|
+
<div className="w-full max-w-sm rounded-2xl border bg-card p-6 text-center shadow-sm">
|
|
108
|
+
<IconBrowserCheck size={36} className="mx-auto text-muted-foreground" />
|
|
109
|
+
<h1 className="mt-4 text-lg font-semibold text-foreground">
|
|
110
|
+
{t("dispatch.pages.browserConnectTitle")}
|
|
111
|
+
</h1>
|
|
112
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
113
|
+
{t("dispatch.pages.browserConnectDescription")}
|
|
114
|
+
</p>
|
|
115
|
+
{!requestIsValid ? (
|
|
116
|
+
<p className="mt-4 text-sm text-destructive">
|
|
117
|
+
{t("dispatch.pages.browserConnectInvalid")}
|
|
118
|
+
</p>
|
|
119
|
+
) : connected ? (
|
|
120
|
+
<p className="mt-4 text-sm font-medium text-foreground">
|
|
121
|
+
{t("dispatch.pages.browserConnectConnected")}
|
|
122
|
+
</p>
|
|
123
|
+
) : (
|
|
124
|
+
<Button
|
|
125
|
+
className="mt-5 w-full gap-2"
|
|
126
|
+
onClick={() => void connect()}
|
|
127
|
+
disabled={createSession.isPending}
|
|
128
|
+
>
|
|
129
|
+
<IconPlugConnected size={16} />
|
|
130
|
+
{createSession.isPending
|
|
131
|
+
? t("dispatch.pages.browserConnectConnecting")
|
|
132
|
+
: t("dispatch.pages.browserConnectButton")}
|
|
133
|
+
</Button>
|
|
134
|
+
)}
|
|
135
|
+
{error ? (
|
|
136
|
+
<p className="mt-3 text-sm text-destructive" role="alert">
|
|
137
|
+
{error}
|
|
138
|
+
</p>
|
|
139
|
+
) : null}
|
|
140
|
+
</div>
|
|
141
|
+
</main>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isBrowserExtensionIdAllowed,
|
|
5
|
+
resolveAllowedBrowserExtensionIds,
|
|
6
|
+
} from "./browser-extension-allowlist.js";
|
|
7
|
+
|
|
8
|
+
const extensionId = "abcdefghijklmnopabcdefghijklmnop";
|
|
9
|
+
|
|
10
|
+
describe("browser extension allowlist", () => {
|
|
11
|
+
it("combines exact configured and environment ids", () => {
|
|
12
|
+
expect(
|
|
13
|
+
resolveAllowedBrowserExtensionIds(
|
|
14
|
+
[extensionId, extensionId],
|
|
15
|
+
"ponmlkjihgfedcbaponmlkjihgfedcba",
|
|
16
|
+
),
|
|
17
|
+
).toEqual(new Set([extensionId, "ponmlkjihgfedcbaponmlkjihgfedcba"]));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("fails loudly for invalid entries instead of producing an empty list", () => {
|
|
21
|
+
expect(() =>
|
|
22
|
+
resolveAllowedBrowserExtensionIds([], "not-an-extension-id"),
|
|
23
|
+
).toThrow("invalid Chrome extension id");
|
|
24
|
+
expect(() =>
|
|
25
|
+
resolveAllowedBrowserExtensionIds(
|
|
26
|
+
Array.from({ length: 65 }, (_, index) => {
|
|
27
|
+
const suffix = index
|
|
28
|
+
.toString(16)
|
|
29
|
+
.padStart(2, "0")
|
|
30
|
+
.replace(/[0-9a-f]/g, (digit) =>
|
|
31
|
+
"abcdefghijklmnop".charAt("0123456789abcdef".indexOf(digit)),
|
|
32
|
+
);
|
|
33
|
+
return `${"a".repeat(30)}${suffix}`;
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
).toThrow("cannot exceed 64");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("requires an exact production allowlist match", () => {
|
|
40
|
+
expect(
|
|
41
|
+
isBrowserExtensionIdAllowed({
|
|
42
|
+
extensionId,
|
|
43
|
+
configIds: [extensionId],
|
|
44
|
+
nodeEnv: "production",
|
|
45
|
+
requestOrigin: "https://dispatch.example.com",
|
|
46
|
+
}),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
expect(
|
|
49
|
+
isBrowserExtensionIdAllowed({
|
|
50
|
+
extensionId,
|
|
51
|
+
configIds: [],
|
|
52
|
+
nodeEnv: "production",
|
|
53
|
+
requestOrigin: "http://localhost:8092",
|
|
54
|
+
}),
|
|
55
|
+
).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("allows unpacked extension ids only from a loopback non-production app", () => {
|
|
59
|
+
expect(
|
|
60
|
+
isBrowserExtensionIdAllowed({
|
|
61
|
+
extensionId,
|
|
62
|
+
configIds: [],
|
|
63
|
+
nodeEnv: "development",
|
|
64
|
+
requestOrigin: "http://localhost:8092",
|
|
65
|
+
}),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
expect(
|
|
68
|
+
isBrowserExtensionIdAllowed({
|
|
69
|
+
extensionId,
|
|
70
|
+
configIds: [],
|
|
71
|
+
nodeEnv: "development",
|
|
72
|
+
requestOrigin: "https://dispatch.example.com",
|
|
73
|
+
}),
|
|
74
|
+
).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const CHROME_EXTENSION_ID = /^[a-p]{32}$/;
|
|
2
|
+
const MAX_BROWSER_EXTENSION_IDS = 64;
|
|
3
|
+
|
|
4
|
+
export function resolveAllowedBrowserExtensionIds(
|
|
5
|
+
configIds: readonly string[] = [],
|
|
6
|
+
envValue = process.env.AGENT_NATIVE_BROWSER_EXTENSION_IDS,
|
|
7
|
+
): Set<string> {
|
|
8
|
+
const ids = [...configIds, ...(envValue?.split(",") ?? [])]
|
|
9
|
+
.map((value) => value.trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
const invalid = ids.find((value) => !CHROME_EXTENSION_ID.test(value));
|
|
12
|
+
if (invalid) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"Browser extension allowlist contains an invalid Chrome extension id.",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const uniqueIds = new Set(ids);
|
|
18
|
+
if (uniqueIds.size > MAX_BROWSER_EXTENSION_IDS) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Browser extension allowlist cannot exceed ${MAX_BROWSER_EXTENSION_IDS} ids.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return uniqueIds;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isLoopbackOrigin(value: string | undefined): boolean {
|
|
27
|
+
if (!value) return false;
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(value);
|
|
30
|
+
return (
|
|
31
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
32
|
+
(url.hostname === "localhost" ||
|
|
33
|
+
url.hostname === "127.0.0.1" ||
|
|
34
|
+
url.hostname === "[::1]")
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isBrowserExtensionIdAllowed(options: {
|
|
42
|
+
extensionId: string;
|
|
43
|
+
configIds?: readonly string[];
|
|
44
|
+
envValue?: string;
|
|
45
|
+
nodeEnv?: string;
|
|
46
|
+
requestOrigin?: string;
|
|
47
|
+
}): boolean {
|
|
48
|
+
if (!CHROME_EXTENSION_ID.test(options.extensionId)) return false;
|
|
49
|
+
const configured = resolveAllowedBrowserExtensionIds(
|
|
50
|
+
options.configIds,
|
|
51
|
+
options.envValue,
|
|
52
|
+
);
|
|
53
|
+
if (configured.has(options.extensionId)) return true;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
options.nodeEnv !== "production" && isLoopbackOrigin(options.requestOrigin)
|
|
57
|
+
);
|
|
58
|
+
}
|