refactor: remove legacy admin settings handling and simplify policy state loading
Kim committed
May 29, 2026 at 17:07 UTC
45697c41b6f9ee11ed64492085958358db5897d0
7 files changed
+77
-45
cmd/relay-server/api.go
+3
-18
@@ -47,7 +47,7 @@ func NewRelayAPI(server *portal.Server, identityPath string, adminWallets []stri
47
if policyStatePath == "" {
48
return nil, errors.New("relay api requires identity path")
49
}
50
- if err := loadPolicyState(policyStatePath, identity.ResolveLegacyRelayPolicyPath(identityPath), server); err != nil {
50
+ if err := loadPolicyState(policyStatePath, server); err != nil {
51
return nil, err
52
}
53
relayIdentity := server.RelayIdentity()
@@ -107,9 +107,8 @@ func (api *RelayAPI) servePublicState(w http.ResponseWriter, r *http.Request) {
107
})
108
}
109
110
-func loadPolicyState(path, legacyPath string, server *portal.Server) error {
110
+func loadPolicyState(path string, server *portal.Server) error {
111
path = strings.TrimSpace(path)
112
- legacyPath = strings.TrimSpace(legacyPath)
112
if path == "" {
113
return nil
114
}
@@ -119,24 +118,10 @@ func loadPolicyState(path, legacyPath string, server *portal.Server) error {
118
if err != nil {
119
return err
120
}
122
- loadedFromLegacy := false
123
- if !loaded && legacyPath != "" && legacyPath != path {
124
- loaded, err = utils.ReadJSONFileIfExists(legacyPath, &payload)
125
- if err != nil {
126
- return err
127
- }
128
- loadedFromLegacy = loaded
129
- }
121
if !loaded {
122
return nil
123
}
133
- if err := payload.apply(server); err != nil {
134
- return err
135
- }
136
- if loadedFromLegacy {
137
- savePolicyState(path, server.PolicyRuntime())
138
- }
139
- return nil
124
+ return payload.apply(server)
125
}
126
127
func (api *RelayAPI) serveAdmin(w http.ResponseWriter, r *http.Request) {
docs/src/routes/configuration/+page.md
+1
-2
@@ -318,8 +318,7 @@ address.
318
319
Persists relay policy state. Managed automatically by the relay on write; do not edit manually while the server is running.
320
321
-Relay policy settings are stored at `IDENTITY_PATH/policy.json`. Older
322
-`admin_settings.json` files are read once and migrated to `policy.json`.
321
+Relay policy settings are stored at `IDENTITY_PATH/policy.json`.
322
323
---
324
frontend/api/server.ts
+54
-8
@@ -108,6 +108,12 @@ interface CDPWaiter {
108
method: string;
109
sessionId?: string;
110
resolve: (message: CDPReply) => void;
111
+ reject: (error: Error) => void;
112
+}
113
+
114
+interface CDPPendingReply {
115
+ resolve: (result: Record<string, unknown>) => void;
116
+ reject: (error: Error) => void;
117
}
118
119
const thumbnailCache = new Map<string, ThumbnailEntry>();
@@ -551,22 +557,24 @@ async function resolveCDPWebSocketURL(): Promise<string> {
557
558
class CDPClient {
559
private nextID = 1;
554
- private readonly pendingReplies = new Map<
555
- number,
556
- { resolve: (result: Record<string, unknown>) => void; reject: (error: Error) => void }
557
- >();
560
+ private readonly pendingReplies = new Map<number, CDPPendingReply>();
561
private readonly waiters: CDPWaiter[] = [];
562
private readonly socket: WebSocket;
563
564
constructor(url: string) {
565
this.socket = new WebSocket(url);
566
this.socket.addEventListener("message", (event) => this.handleMessage(event));
567
+ this.socket.addEventListener("close", () => this.handleClose());
568
+ this.socket.addEventListener("error", () => this.handleClose(new Error("WebSocket error")));
569
}
570
571
connect(): Promise<void> {
572
if (this.socket.readyState === WebSocket.OPEN) {
573
return Promise.resolve();
574
}
575
+ if (this.socket.readyState === WebSocket.CLOSING || this.socket.readyState === WebSocket.CLOSED) {
576
+ return Promise.reject(new Error("WebSocket connection closed"));
577
+ }
578
return new Promise((resolve, reject) => {
579
const timeout = setTimeout(() => {
580
reject(new Error("connect to headless shell timed out"));
@@ -588,6 +596,14 @@ class CDPClient {
596
},
597
{ once: true }
598
);
599
+ this.socket.addEventListener(
600
+ "close",
601
+ () => {
602
+ clearTimeout(timeout);
603
+ reject(new Error("WebSocket connection closed"));
604
+ },
605
+ { once: true }
606
+ );
607
});
608
}
609
@@ -608,11 +624,15 @@ class CDPClient {
624
}
625
626
return new Promise((resolve, reject) => {
627
+ if (this.socket.readyState !== WebSocket.OPEN) {
628
+ reject(new Error("WebSocket connection is not open"));
629
+ return;
630
+ }
631
const timeout = setTimeout(() => {
632
this.pendingReplies.delete(id);
633
reject(new Error(`${method} timed out`));
634
}, timeoutMs);
615
- this.pendingReplies.set(id, {
635
+ const pendingReply: CDPPendingReply = {
636
resolve: (result) => {
637
clearTimeout(timeout);
638
resolve(result);
@@ -621,14 +641,24 @@ class CDPClient {
641
clearTimeout(timeout);
642
reject(error);
643
},
624
- });
625
- this.socket.send(JSON.stringify(payload));
644
+ };
645
+ this.pendingReplies.set(id, pendingReply);
646
+ try {
647
+ this.socket.send(JSON.stringify(payload));
648
+ } catch (error) {
649
+ this.pendingReplies.delete(id);
650
+ pendingReply.reject(error instanceof Error ? error : new Error("CDP send failed"));
651
+ }
652
});
653
}
654
655
waitForEvent(method: string, sessionId: string, timeoutMs: number): Promise<CDPReply> {
656
return new Promise((resolve, reject) => {
631
- const waiter: CDPWaiter = { method, sessionId, resolve };
657
+ if (this.socket.readyState !== WebSocket.OPEN) {
658
+ reject(new Error("WebSocket connection is not open"));
659
+ return;
660
+ }
661
+ const waiter: CDPWaiter = { method, sessionId, resolve, reject };
662
this.waiters.push(waiter);
663
const timeout = setTimeout(() => {
664
const index = this.waiters.indexOf(waiter);
@@ -642,9 +672,25 @@ class CDPClient {
672
clearTimeout(timeout);
673
resolve(message);
674
};
675
+ waiter.reject = (error) => {
676
+ clearTimeout(timeout);
677
+ reject(error);
678
+ };
679
});
680
}
681
682
+ private handleClose(error = new Error("WebSocket connection closed")): void {
683
+ for (const pendingReply of this.pendingReplies.values()) {
684
+ pendingReply.reject(error);
685
+ }
686
+ this.pendingReplies.clear();
687
+
688
+ const waiters = this.waiters.splice(0);
689
+ for (const waiter of waiters) {
690
+ waiter.reject(error);
691
+ }
692
+ }
693
+
694
private handleMessage(event: MessageEvent): void {
695
const message = JSON.parse(String(event.data)) as CDPReply;
696
if (typeof message.id === "number") {
frontend/src/lib/apiClient.test.ts
+12
@@ -14,6 +14,7 @@ describe("apiClient", () => {
14
const fetchMock = vi.fn();
15
16
beforeEach(() => {
17
+ vi.unstubAllEnvs();
18
fetchMock.mockReset();
19
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
20
localStorage.clear();
@@ -33,6 +34,17 @@ describe("apiClient", () => {
34
expect(init.headers).toEqual({ Accept: "application/json" });
35
});
36
37
+ it("preserves API base URL subpaths", async () => {
38
+ vi.stubEnv("VITE_PORTAL_API_BASE_URL", "https://portal.example.com/api");
39
+ fetchMock.mockResolvedValueOnce(
40
+ jsonResponse({ ok: true, data: { status: "ok" } }),
41
+ );
42
+
43
+ await apiClient.get("/state");
44
+
45
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://portal.example.com/api/state");
46
+ });
47
+
48
it("rejects successful non-envelope JSON payloads", async () => {
49
fetchMock.mockResolvedValueOnce(jsonResponse({ direct: true }));
50
frontend/src/lib/apiClient.ts
+3
-4
@@ -29,10 +29,9 @@ function resolveAPIURL(path: string): string {
29
if (!baseURL) {
30
return path;
31
}
32
- return new URL(
33
- path,
34
- baseURL.endsWith("/") ? baseURL : `${baseURL}/`
35
- ).toString();
32
+ const normalizedBase = baseURL.endsWith("/") ? baseURL.slice(0, -1) : baseURL;
33
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
34
+ return `${normalizedBase}${normalizedPath}`;
35
}
36
37
function ensureJsonEnvelope<T>(raw: unknown, path: string, status: number): APIEnvelope<T> {
portal/identity/store.go
+1
-9
@@ -114,7 +114,7 @@ func ResolveRelayStateDir(path string) string {
114
return ""
115
}
116
switch strings.ToLower(filepath.Base(trimmed)) {
117
- case types.RelayIdentityFilename, types.RelayPolicyFilename, types.LegacyRelayAdminSettingsFilename:
117
+ case types.RelayIdentityFilename, types.RelayPolicyFilename:
118
return filepath.Dir(trimmed)
119
default:
120
return trimmed
@@ -137,14 +137,6 @@ func ResolveRelayPolicyPath(path string) string {
137
return filepath.Join(stateDir, types.RelayPolicyFilename)
138
}
139
140
-func ResolveLegacyRelayPolicyPath(path string) string {
141
- stateDir := ResolveRelayStateDir(path)
142
- if stateDir == "" {
143
- return ""
144
- }
145
- return filepath.Join(stateDir, types.LegacyRelayAdminSettingsFilename)
146
-}
147
-
140
func normalizeStoredIdentity(identity types.Identity) (types.Identity, error) {
141
normalized := identity.Copy()
142
normalized.Name = strings.TrimSpace(normalized.Name)
types/identity.go
+3
-4
@@ -7,10 +7,9 @@ import (
7
)
8
9
const (
10
- IdentityKeySeparator = ":"
11
- RelayIdentityFilename = "identity.json"
12
- RelayPolicyFilename = "policy.json"
13
- LegacyRelayAdminSettingsFilename = "admin_settings.json"
10
+ IdentityKeySeparator = ":"
11
+ RelayIdentityFilename = "identity.json"
12
+ RelayPolicyFilename = "policy.json"
13
)
14
15
type Identity struct {