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 {