From df77d786901beec795ec3f0bae313140dff3703a Mon Sep 17 00:00:00 2001 From: Joshua Morris Date: Mon, 6 Apr 2026 07:35:35 -0700 Subject: [PATCH 1/4] Harden browser access to local server origins --- Perspective Server/LocalHTTPServer.swift | 27 ++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/Perspective Server/LocalHTTPServer.swift b/Perspective Server/LocalHTTPServer.swift index b220f31..e6ffba8 100644 --- a/Perspective Server/LocalHTTPServer.swift +++ b/Perspective Server/LocalHTTPServer.swift @@ -30,14 +30,11 @@ actor LocalHTTPServer { // already running on the user's machine with full filesystem access. // This matches how Ollama and similar local AI servers handle security. - /// Origins allowed to make cross-origin requests from a browser. - /// Localhost covers local dev and browser extensions. - /// The Perspective Intelligence web app is allowed so Basic tier users - /// can stream directly from the browser to their Mac. + /// Origins allowed to make requests from a browser context. + /// Keep this loopback-only to prevent arbitrary public websites from + /// driving the local model via cross-site browser requests. private static let allowedOriginHosts: Set = [ "localhost", "127.0.0.1", "[::1]", "::1", - "perspectiveintelligence.app", - "www.perspectiveintelligence.app", ] // MARK: - Pairing @@ -94,6 +91,15 @@ actor LocalHTTPServer { return allowedOriginHosts.contains(host.lowercased()) } + /// Browser requests that omit Origin can still be cross-site (e.g. some no-cors subresource loads). + /// If Fetch Metadata headers are present, treat this as browser traffic and require Origin. + nonisolated private static func isBrowserRequestMissingOrigin(headers: [String: String], origin: String?) -> Bool { + guard origin == nil else { return false } + let hasSecFetchSite = headers["sec-fetch-site"] != nil + let hasSecFetchMode = headers["sec-fetch-mode"] != nil + return hasSecFetchSite || hasSecFetchMode + } + nonisolated private static func jsonHeaders(corsOrigin: String? = nil) -> [String: String] { var headers = ["Content-Type": "application/json"] if let corsOrigin, !corsOrigin.isEmpty { @@ -178,6 +184,15 @@ actor LocalHTTPServer { let serverPort = await self.port let host = request.headers["host"] let origin = request.headers["origin"] + + // 1a. Require Origin for browser-context requests to close no-origin CSRF paths. + if Self.isBrowserRequestMissingOrigin(headers: request.headers, origin: origin) { + logger.warning("[req:\(rid, privacy: .public)] Blocked: browser request missing Origin header") + let msg = ["error": ["message": "Forbidden: browser requests must include Origin"]] + let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data() + return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(), body: data)) + } + let validatedCorsOrigin = Self.isAllowedOrigin(origin) ? (origin ?? "") : "" if !Self.isAllowedHost(host, serverPort: serverPort) { logger.warning("[req:\(rid, privacy: .public)] Blocked: invalid Host header '\(host ?? "nil", privacy: .public)'") From 31c4a0d340f34f34f614a3628e03ddf6b263eb74 Mon Sep 17 00:00:00 2001 From: Joshua Morris Date: Mon, 6 Apr 2026 07:39:04 -0700 Subject: [PATCH 2/4] Allow official web app origins in browser allowlist --- Perspective Server/LocalHTTPServer.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Perspective Server/LocalHTTPServer.swift b/Perspective Server/LocalHTTPServer.swift index e6ffba8..fbccf76 100644 --- a/Perspective Server/LocalHTTPServer.swift +++ b/Perspective Server/LocalHTTPServer.swift @@ -32,9 +32,12 @@ actor LocalHTTPServer { /// Origins allowed to make requests from a browser context. /// Keep this loopback-only to prevent arbitrary public websites from - /// driving the local model via cross-site browser requests. + /// driving the local model via cross-site browser requests, while still + /// allowing the official Perspective Intelligence web app integration. private static let allowedOriginHosts: Set = [ "localhost", "127.0.0.1", "[::1]", "::1", + "perspectiveintelligence.app", + "www.perspectiveintelligence.app", ] // MARK: - Pairing From 78de29fb74badefe26ad11de6651275aee893328 Mon Sep 17 00:00:00 2001 From: Joshua Morris Date: Mon, 6 Apr 2026 11:08:25 -0700 Subject: [PATCH 3/4] Add approved-client token access control and dashboard settings integration --- Perspective Server/AccessControlManager.swift | 204 ++++++++++++ Perspective Server/ChatView.swift | 5 +- Perspective Server/LocalHTTPServer.swift | 49 +-- .../Perspective_ServerApp.swift | 6 +- Perspective Server/RelayClient.swift | 3 + Perspective Server/ServerDashboardView.swift | 161 ++++++--- Perspective Server/SettingsView.swift | 307 +++++++++++++++++- 7 files changed, 655 insertions(+), 80 deletions(-) create mode 100644 Perspective Server/AccessControlManager.swift diff --git a/Perspective Server/AccessControlManager.swift b/Perspective Server/AccessControlManager.swift new file mode 100644 index 0000000..a8b43ea --- /dev/null +++ b/Perspective Server/AccessControlManager.swift @@ -0,0 +1,204 @@ +import Foundation + +struct ApprovedClient: Codable, Identifiable, Sendable, Equatable { + let id: UUID + var name: String + var token: String + var allowedOriginHosts: [String] + var allowedPathPrefixes: [String] + var isEnabled: Bool + var isSystemClient: Bool + let createdAt: Date + + func allowsOrigin(_ originHost: String?) -> Bool { + guard let originHost else { return true } + return allowedOriginHosts.contains(originHost.lowercased()) + } + + func allowsPath(_ path: String) -> Bool { + allowedPathPrefixes.contains { path.hasPrefix($0) } + } +} + +enum AccessDecision: Sendable { + case allow + case deny(status: Int, message: String) +} + +actor AccessControlManager { + static let shared = AccessControlManager() + + private let defaultsKey = "approvedApiClientsV1" + private var clients: [ApprovedClient] = [] + + init() { + clients = Self.loadClients(defaultsKey: defaultsKey) + if clients.isEmpty { + clients = Self.defaultClients() + Self.saveClients(clients, defaultsKey: defaultsKey) + } + } + + func listClients() -> [ApprovedClient] { + clients.sorted { lhs, rhs in + if lhs.isSystemClient != rhs.isSystemClient { + return lhs.isSystemClient && !rhs.isSystemClient + } + return lhs.createdAt < rhs.createdAt + } + } + + func createClient(name: String, allowedOriginHosts: [String], allowedPathPrefixes: [String] = ["/v1/", "/api/"]) -> ApprovedClient { + let client = ApprovedClient( + id: UUID(), + name: name, + token: Self.generateToken(), + allowedOriginHosts: Self.normalizeOrigins(allowedOriginHosts), + allowedPathPrefixes: allowedPathPrefixes, + isEnabled: true, + isSystemClient: false, + createdAt: Date() + ) + clients.append(client) + persist() + return client + } + + @discardableResult + func rotateToken(id: UUID) -> String? { + guard let index = clients.firstIndex(where: { $0.id == id }) else { return nil } + let newToken = Self.generateToken() + clients[index].token = newToken + persist() + return newToken + } + + func deleteClient(id: UUID) { + guard let index = clients.firstIndex(where: { $0.id == id }) else { return } + if clients[index].isSystemClient { return } + clients.remove(at: index) + persist() + } + + func setClientEnabled(id: UUID, enabled: Bool) { + guard let index = clients.firstIndex(where: { $0.id == id }) else { return } + clients[index].isEnabled = enabled + persist() + } + + func tokenForLocalApp() -> String? { + clients.first(where: { $0.isSystemClient && $0.name == "Local App" })?.token + } + + func tokenForOrigin(_ originHost: String?) -> String? { + guard let originHost = originHost?.lowercased() else { return nil } + return clients.first(where: { $0.isEnabled && $0.allowedOriginHosts.contains(originHost) })?.token + } + + func allowedBrowserOrigins() -> Set { + Set(clients.filter { $0.isEnabled }.flatMap { $0.allowedOriginHosts }) + } + + func authorize(path: String, authHeader: String?, originHost: String?) -> AccessDecision { + guard isProtectedPath(path) else { return .allow } + + guard let token = Self.bearerToken(from: authHeader) else { + return .deny(status: 401, message: "Unauthorized: missing bearer token") + } + + guard let client = clients.first(where: { $0.token == token }) else { + return .deny(status: 401, message: "Unauthorized: invalid bearer token") + } + + guard client.isEnabled else { + return .deny(status: 403, message: "Forbidden: client is disabled") + } + + guard client.allowsPath(path) else { + return .deny(status: 403, message: "Forbidden: token not allowed for this endpoint") + } + + guard client.allowsOrigin(originHost) else { + return .deny(status: 403, message: "Forbidden: origin not approved for this token") + } + + return .allow + } + + private func persist() { + Self.saveClients(clients, defaultsKey: defaultsKey) + } + + private static func loadClients(defaultsKey: String) -> [ApprovedClient] { + guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { + return [] + } + return (try? JSONDecoder().decode([ApprovedClient].self, from: data)) ?? [] + } + + private static func saveClients(_ clients: [ApprovedClient], defaultsKey: String) { + if let data = try? JSONEncoder().encode(clients) { + UserDefaults.standard.set(data, forKey: defaultsKey) + } + } + + private static func defaultClients() -> [ApprovedClient] { + let localOrigins = ["localhost", "127.0.0.1", "[::1]", "::1"] + + let localAppClient = ApprovedClient( + id: UUID(), + name: "Local App", + token: Self.generateToken(), + allowedOriginHosts: localOrigins, + allowedPathPrefixes: ["/v1/", "/api/"], + isEnabled: true, + isSystemClient: true, + createdAt: Date() + ) + + let webClient = ApprovedClient( + id: UUID(), + name: "Perspective Intelligence Web", + token: Self.generateToken(), + allowedOriginHosts: ["perspectiveintelligence.app", "www.perspectiveintelligence.app"], + allowedPathPrefixes: ["/v1/", "/api/"], + isEnabled: true, + isSystemClient: true, + createdAt: Date() + ) + + return [localAppClient, webClient] + } + + private func isProtectedPath(_ path: String) -> Bool { + path.hasPrefix("/v1/") || path.hasPrefix("/api/") + } + + private static func normalizeOrigins(_ origins: [String]) -> [String] { + var set: Set = [] + for value in origins { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if !trimmed.isEmpty { + set.insert(trimmed) + } + } + return Array(set).sorted() + } + + private static func bearerToken(from header: String?) -> String? { + guard let header else { return nil } + guard header.lowercased().hasPrefix("bearer ") else { return nil } + let token = String(header.dropFirst(7)).trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? nil : token + } + + private static func generateToken() -> String { + let chars = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + var value = "pi_" + value.reserveCapacity(35) + for _ in 0..<32 { + value.append(chars[Int.random(in: 0.. = [ - "localhost", "127.0.0.1", "[::1]", "::1", - "perspectiveintelligence.app", - "www.perspectiveintelligence.app", - ] - // MARK: - Pairing /// 6-digit pairing code for connecting the web app to this server. @@ -88,10 +80,10 @@ actor LocalHTTPServer { /// Check if an Origin header value is in the allowlist by parsing the URL host. /// Browser-enforced Origin headers cannot be spoofed by JavaScript, so this /// provides reliable cross-origin protection without requiring a bearer token. - nonisolated private static func isAllowedOrigin(_ origin: String?) -> Bool { + nonisolated private static func isAllowedOrigin(_ origin: String?, allowedHosts: Set) -> Bool { guard let origin = origin else { return true } // No Origin = not a browser cross-origin request guard let url = URL(string: origin), let host = url.host else { return false } - return allowedOriginHosts.contains(host.lowercased()) + return allowedHosts.contains(host.lowercased()) } /// Browser requests that omit Origin can still be cross-site (e.g. some no-cors subresource loads). @@ -187,6 +179,7 @@ actor LocalHTTPServer { let serverPort = await self.port let host = request.headers["host"] let origin = request.headers["origin"] + let approvedOriginHosts = await AccessControlManager.shared.allowedBrowserOrigins() // 1a. Require Origin for browser-context requests to close no-origin CSRF paths. if Self.isBrowserRequestMissingOrigin(headers: request.headers, origin: origin) { @@ -196,7 +189,7 @@ actor LocalHTTPServer { return .normal(HTTPResponse(status: 403, headers: Self.jsonHeaders(), body: data)) } - let validatedCorsOrigin = Self.isAllowedOrigin(origin) ? (origin ?? "") : "" + let validatedCorsOrigin = Self.isAllowedOrigin(origin, allowedHosts: approvedOriginHosts) ? (origin ?? "") : "" if !Self.isAllowedHost(host, serverPort: serverPort) { logger.warning("[req:\(rid, privacy: .public)] Blocked: invalid Host header '\(host ?? "nil", privacy: .public)'") let msg = ["error": ["message": "Forbidden: invalid Host header"]] @@ -205,7 +198,7 @@ actor LocalHTTPServer { } // 2. Validate Origin header (cross-origin protection) - if !Self.isAllowedOrigin(origin) { + if !Self.isAllowedOrigin(origin, allowedHosts: approvedOriginHosts) { logger.warning("[req:\(rid, privacy: .public)] Blocked: disallowed Origin '\(origin ?? "nil", privacy: .public)'") let msg = ["error": ["message": "Forbidden: origin not allowed"]] let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data() @@ -214,6 +207,8 @@ actor LocalHTTPServer { // Compute CORS origin: echo back the validated origin, or empty if no Origin header let corsOrigin = validatedCorsOrigin + let originHost = URL(string: origin ?? "")?.host?.lowercased() + let rawPath = request.path.split(separator: "?").first.map(String.init) ?? request.path // CORS preflight support (no auth token required for preflight) if request.method == "OPTIONS" { @@ -225,16 +220,15 @@ actor LocalHTTPServer { ], body: Data())) } - // No bearer token required — security is provided by: - // 1. Loopback-only binding (not reachable from network) - // 2. CORS origin allowlist (blocks random websites) - // 3. Host header validation (blocks DNS rebinding) + // Security stack: + // 1. Loopback-only binding + // 2. Host + Origin validation + // 3. Per-application bearer tokens on protected API/model routes // --- Pairing endpoint --- // POST /pair/verify { "code": "123456" } // Returns 200 with server info if code matches, 403 if not. // Only accessible from allowed origins (CORS validated above). - let rawPath = request.path.split(separator: "?").first.map(String.init) ?? request.path if request.method == "POST" && rawPath == "/pair/verify" { let currentCode = await self.pairingCode guard !currentCode.isEmpty else { @@ -256,6 +250,7 @@ actor LocalHTTPServer { "paired": true, "port": serverPort, "server": "Perspective Intelligence Server", + "apiToken": await AccessControlManager.shared.tokenForOrigin(originHost) ?? "", ] let data = (try? JSONSerialization.data(withJSONObject: obj, options: [])) ?? Data() return .normal(HTTPResponse(status: 200, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data)) @@ -266,6 +261,18 @@ actor LocalHTTPServer { } } + // Require per-application bearer tokens for model/API endpoints. + let authHeader = request.headers["authorization"] ?? request.headers["Authorization"] + switch await AccessControlManager.shared.authorize(path: rawPath, authHeader: authHeader, originHost: originHost) { + case .allow: + break + case .deny(let status, let message): + logger.warning("[req:\(rid, privacy: .public)] Auth denied: \(message, privacy: .public)") + let msg = ["error": ["message": message]] + let data = (try? JSONSerialization.data(withJSONObject: msg, options: [])) ?? Data() + return .normal(HTTPResponse(status: status, headers: Self.jsonHeaders(corsOrigin: corsOrigin), body: data)) + } + // Normalize path: strip query string and trailing slash let basePath: String = { if let q = request.path.firstIndex(of: "?") { return String(request.path[.. Void ) -> some View { Button(action: action) { - VStack(spacing: 6) { - Image(systemName: icon) - .font(.title2) - .accessibilityHidden(true) - Text(title) - .font(.subheadline.weight(.medium)) - Text(subtitle) - .font(.caption2) - .foregroundColor(.secondary) - } - .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(Color(NSColor.textBackgroundColor)) - .cornerRadius(12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color(NSColor.separatorColor), lineWidth: 1) - ) + actionButtonLabel(title: title, subtitle: subtitle, icon: icon) } .buttonStyle(.plain) .accessibilityLabel(accessibilityLabel ?? "\(title), \(subtitle)") .accessibilityValue(accessibilityValue ?? "") } + + private func actionButtonLabel(title: String, subtitle: String, icon: String) -> some View { + VStack(spacing: 6) { + Image(systemName: icon) + .font(.title2) + .accessibilityHidden(true) + Text(title) + .font(.subheadline.weight(.medium)) + Text(subtitle) + .font(.caption2) + .foregroundColor(.secondary) + } + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + } // MARK: - Test Connection Card @@ -1041,9 +1087,13 @@ struct ServerDashboardView: View { Task { let running = await LocalHTTPServer.shared.getIsRunning() let port = await LocalHTTPServer.shared.getPort() + let error = await LocalHTTPServer.shared.getLastError() + let code = await LocalHTTPServer.shared.pairingCode await MainActor.run { serverController.isRunning = running serverController.port = port + serverController.errorMessage = error + serverController.pairingCode = code localPort = String(port) } } @@ -1067,7 +1117,10 @@ struct ServerDashboardView: View { Task { let url = URL(string: "http://127.0.0.1:\(serverController.port)/api/tags")! do { - let request = URLRequest(url: url) + var request = URLRequest(url: url) + if let token = await AccessControlManager.shared.tokenForLocalApp() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse { @@ -1108,7 +1161,7 @@ struct ServerDashboardView: View { } } - // Bearer token removed — security is handled by loopback binding + CORS + Host validation + // Protected API routes require per-application bearer tokens. private func isCopied(_ copiedMessage: String) -> Bool { showCopiedToast && copiedText == copiedMessage diff --git a/Perspective Server/SettingsView.swift b/Perspective Server/SettingsView.swift index 4b691cf..95f1099 100644 --- a/Perspective Server/SettingsView.swift +++ b/Perspective Server/SettingsView.swift @@ -6,15 +6,43 @@ // import SwiftUI +#if os(macOS) +import AppKit +#endif struct SettingsView: View { + let embeddedInDashboard: Bool + + init(embeddedInDashboard: Bool = false) { + self.embeddedInDashboard = embeddedInDashboard + } + @AppStorage("systemPrompt") private var systemPrompt: String = "You are a helpful assistant. Keep responses concise and relevant." @AppStorage("includeSystemPrompt") private var includeSystemPrompt: Bool = false @AppStorage("debugLogging") private var debugLogging: Bool = false @AppStorage("includeHistory") private var includeHistory: Bool = true @AppStorage("enableBetaUpdates") private var enableBetaUpdates: Bool = false + @State private var clients: [ApprovedClient] = [] + @State private var newClientName: String = "" + @State private var newClientOrigins: String = "localhost" + var body: some View { + Group { + if embeddedInDashboard { + dashboardEmbeddedContent + } else { + formContent + .padding() + .frame(minWidth: 560, minHeight: 420) + } + } + .task { + await loadClients() + } + } + + private var formContent: some View { Form { Toggle("Include System Prompt", isOn: $includeSystemPrompt) .accessibilityLabel("Include system prompt") @@ -43,12 +71,287 @@ struct SettingsView: View { } } } + + Section(header: Text("API Access Control")) { + Text("Approved applications must use a bearer token and can only call from allowed origins.") + .font(.footnote) + .foregroundStyle(.secondary) + + if clients.isEmpty { + Text("No approved clients configured.") + .foregroundStyle(.secondary) + } else { + ForEach(clients) { client in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(client.name) + .font(.subheadline.weight(.semibold)) + if client.isSystemClient { + Text("System") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .cornerRadius(6) + } + Spacer() + Toggle("Enabled", isOn: Binding( + get: { client.isEnabled }, + set: { enabled in + Task { + await AccessControlManager.shared.setClientEnabled(id: client.id, enabled: enabled) + await loadClients() + } + } + )) + .labelsHidden() + } + + Text("Origins: \(client.allowedOriginHosts.joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + Text("Token: \(client.token)") + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + + HStack(spacing: 10) { + Button("Copy Token") { + copyText(client.token) + } + .buttonStyle(.bordered) + + Button("Rotate Token") { + Task { + _ = await AccessControlManager.shared.rotateToken(id: client.id) + await loadClients() + } + } + .buttonStyle(.bordered) + + if !client.isSystemClient { + Button("Delete") { + Task { + await AccessControlManager.shared.deleteClient(id: client.id) + await loadClients() + } + } + .buttonStyle(.bordered) + } + } + } + .padding(.vertical, 4) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Add Approved Client") + .font(.subheadline.weight(.semibold)) + + TextField("Client name", text: $newClientName) + TextField("Allowed origins (comma-separated hosts)", text: $newClientOrigins) + + Button("Create Token") { + Task { + let name = newClientName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + let origins = newClientOrigins + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + _ = await AccessControlManager.shared.createClient(name: name, allowedOriginHosts: origins) + newClientName = "" + await loadClients() + } + } + .buttonStyle(.borderedProminent) + .disabled(newClientName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + Text("The system prompt (if enabled) is sent with each chat to guide the assistant's behavior.") .font(.footnote) .foregroundStyle(.secondary) } - .padding() - .frame(minWidth: 420, minHeight: 260) + } + + private var dashboardEmbeddedContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + card(title: "General", systemImage: "switch.2") { + VStack(alignment: .leading, spacing: 10) { + Toggle("Include System Prompt", isOn: $includeSystemPrompt) + Toggle("Enable Debug Logging", isOn: $debugLogging) + Toggle("Include Conversation History", isOn: $includeHistory) + Toggle("Receive Beta Updates", isOn: $enableBetaUpdates) + } + } + + card(title: "System Prompt", systemImage: "text.quote") { + VStack(alignment: .leading, spacing: 10) { + TextEditor(text: $systemPrompt) + .font(.body) + .frame(minHeight: 140) + .padding(8) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + HStack { + Spacer() + Button("Reset to Default") { + systemPrompt = "You are a helpful assistant. Keep responses concise and relevant." + includeSystemPrompt = true + } + .buttonStyle(.bordered) + } + Text("The system prompt (if enabled) is sent with each chat to guide assistant behavior.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + card(title: "API Access Control", systemImage: "lock.shield") { + VStack(alignment: .leading, spacing: 12) { + Text("Approved applications must use a bearer token and can only call from allowed origins.") + .font(.footnote) + .foregroundStyle(.secondary) + + if clients.isEmpty { + Text("No approved clients configured.") + .foregroundStyle(.secondary) + } else { + ForEach(clients) { client in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(client.name) + .font(.subheadline.weight(.semibold)) + if client.isSystemClient { + Text("System") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.secondary.opacity(0.15)) + .cornerRadius(6) + } + Spacer() + Toggle("Enabled", isOn: Binding( + get: { client.isEnabled }, + set: { enabled in + Task { + await AccessControlManager.shared.setClientEnabled(id: client.id, enabled: enabled) + await loadClients() + } + } + )) + .labelsHidden() + } + + Text("Origins: \(client.allowedOriginHosts.joined(separator: ", "))") + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + Text("Token: \(client.token)") + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + + HStack(spacing: 10) { + Button("Copy Token") { + copyText(client.token) + } + .buttonStyle(.bordered) + + Button("Rotate Token") { + Task { + _ = await AccessControlManager.shared.rotateToken(id: client.id) + await loadClients() + } + } + .buttonStyle(.bordered) + + if !client.isSystemClient { + Button("Delete") { + Task { + await AccessControlManager.shared.deleteClient(id: client.id) + await loadClients() + } + } + .buttonStyle(.bordered) + } + } + } + .padding(12) + .background(Color(NSColor.textBackgroundColor)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Add Approved Client") + .font(.subheadline.weight(.semibold)) + TextField("Client name", text: $newClientName) + TextField("Allowed origins (comma-separated hosts)", text: $newClientOrigins) + Button("Create Token") { + Task { + let name = newClientName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + let origins = newClientOrigins + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + _ = await AccessControlManager.shared.createClient(name: name, allowedOriginHosts: origins) + newClientName = "" + await loadClients() + } + } + .buttonStyle(.borderedProminent) + .disabled(newClientName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + .padding(.vertical, 4) + } + } + + private func card(title: String, systemImage: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label(title, systemImage: systemImage) + .font(.headline) + Divider() + content() + } + .padding(16) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(NSColor.separatorColor), lineWidth: 1) + ) + } + + @MainActor + private func loadClients() async { + clients = await AccessControlManager.shared.listClients() + } + + private func copyText(_ value: String) { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + #endif } } From bda6b55c2be77f5ce42d3cc6bf72c049ad973e03 Mon Sep 17 00:00:00 2001 From: Joshua Morris Date: Mon, 6 Apr 2026 11:23:01 -0700 Subject: [PATCH 4/4] Activate app before opening dashboard and chat windows --- Perspective Server/MenuBarContentView.swift | 5 +++++ Perspective Server/Perspective_ServerApp.swift | 2 ++ 2 files changed, 7 insertions(+) diff --git a/Perspective Server/MenuBarContentView.swift b/Perspective Server/MenuBarContentView.swift index 345cceb..b2de754 100644 --- a/Perspective Server/MenuBarContentView.swift +++ b/Perspective Server/MenuBarContentView.swift @@ -6,6 +6,9 @@ // import SwiftUI +#if os(macOS) +import AppKit +#endif struct MenuBarContentView: View { @Environment(\.openWindow) private var openWindow @@ -17,9 +20,11 @@ struct MenuBarContentView: View { .environmentObject(serverController) Divider() Button("Open Dashboard") { + NSApp.activate(ignoringOtherApps: true) openWindow(id: "dashboard") } Button("Open Chat Window") { + NSApp.activate(ignoringOtherApps: true) openWindow(id: "chat") } Divider() diff --git a/Perspective Server/Perspective_ServerApp.swift b/Perspective Server/Perspective_ServerApp.swift index addb225..8c995be 100644 --- a/Perspective Server/Perspective_ServerApp.swift +++ b/Perspective Server/Perspective_ServerApp.swift @@ -100,9 +100,11 @@ struct Perspective_ServerApp: App { } } .onReceive(NotificationCenter.default.publisher(for: .openChatWindow)) { _ in + NSApp.activate(ignoringOtherApps: true) openWindow(id: "chat") } .onReceive(NotificationCenter.default.publisher(for: .openDashboard)) { _ in + NSApp.activate(ignoringOtherApps: true) openWindow(id: "dashboard") } }