Skip to content

Commit 1260c1a

Browse files
its-mashMohammod Al Amin Ashik
authored andcommitted
feat(P2): restore lock-to-Space wiring deferred from P1 (Strategy Y)
Signed-off-by: Mohammod Al Amin Ashik <maa.ashik00@gmail.com>
1 parent fe636ab commit 1260c1a

4 files changed

Lines changed: 102 additions & 9 deletions

File tree

apps/desktop/src-tauri/src/commands/oauth.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,7 @@ pub async fn delete_oauth_client(
943943
pub struct RegisteredApiKeyClient {
944944
pub client_id: String,
945945
pub client_name: String,
946+
pub locked_space_id: Option<String>,
946947
pub api_key: String,
947948
pub key_prefix: String,
948949
}
@@ -973,12 +974,13 @@ fn generate_api_key() -> (String, String, String) {
973974
(key_id, plaintext, key_prefix)
974975
}
975976

976-
/// Register a new pre-approved client authenticated by an API key. The returned
977-
/// `api_key` is shown once and never stored.
977+
/// Register a new pre-approved client authenticated by an API key, optionally
978+
/// locked to a Space. The returned `api_key` is shown once and never stored.
978979
#[tauri::command]
979980
pub async fn register_api_key_client(
980981
gateway_state: State<'_, Arc<RwLock<GatewayAppState>>>,
981982
name: String,
983+
locked_space_id: Option<String>,
982984
) -> Result<RegisteredApiKeyClient, String> {
983985
let app_state = gateway_state.read().await;
984986
let Some(ref gw_state) = app_state.gateway_state else {
@@ -1024,6 +1026,12 @@ pub async fn register_api_key_client(
10241026
.await
10251027
.map_err(|e| format!("Failed to create client: {}", e))?;
10261028

1029+
if let Some(ref space) = locked_space_id {
1030+
repo.set_locked_space(&client_id, Some(space))
1031+
.await
1032+
.map_err(|e| format!("Failed to lock client to space: {}", e))?;
1033+
}
1034+
10271035
let (key_id, plaintext, key_prefix) = generate_api_key();
10281036
repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None)
10291037
.await
@@ -1037,6 +1045,7 @@ pub async fn register_api_key_client(
10371045
Ok(RegisteredApiKeyClient {
10381046
client_id,
10391047
client_name: trimmed.to_string(),
1048+
locked_space_id,
10401049
api_key: plaintext,
10411050
key_prefix,
10421051
})
@@ -1079,9 +1088,15 @@ pub async fn create_client_api_key(
10791088
.await
10801089
.map_err(|e| format!("Failed to create API key: {}", e))?;
10811090

1091+
let locked_space_id = repo
1092+
.get_locked_space(&client_id)
1093+
.await
1094+
.map_err(|e| format!("Failed to read client: {}", e))?;
1095+
10821096
Ok(RegisteredApiKeyClient {
10831097
client_id,
10841098
client_name: client.client_name,
1099+
locked_space_id,
10851100
api_key: plaintext,
10861101
key_prefix,
10871102
})

apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
* never display it again — if lost, revoke it and issue a new one.
1111
*/
1212

13-
import { useState } from 'react';
14-
import { AlertTriangle, Check, Copy, KeyRound, Loader2, ShieldCheck, X } from 'lucide-react';
13+
import { useEffect, useState } from 'react';
14+
import { AlertTriangle, Check, Copy, KeyRound, Loader2, Lock, ShieldCheck, X } from 'lucide-react';
1515
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui';
1616
import { registerApiKeyClient, type RegisteredApiKeyClient } from '@/lib/api/gateway';
17+
import { listSpaces, type Space } from '@/lib/api/spaces';
1718

1819
interface RegisterApiKeyClientModalProps {
1920
onClose: () => void;
@@ -26,11 +27,23 @@ export function RegisterApiKeyClientModal({
2627
onRegistered,
2728
}: RegisterApiKeyClientModalProps) {
2829
const [name, setName] = useState('');
30+
const [lockedSpaceId, setLockedSpaceId] = useState('');
31+
const [spaces, setSpaces] = useState<Space[]>([]);
2932
const [isSubmitting, setIsSubmitting] = useState(false);
3033
const [error, setError] = useState<string | null>(null);
3134
const [result, setResult] = useState<RegisteredApiKeyClient | null>(null);
3235
const [copied, setCopied] = useState(false);
3336

37+
useEffect(() => {
38+
listSpaces()
39+
.then(setSpaces)
40+
.catch(() => setSpaces([]));
41+
}, []);
42+
43+
const lockedSpaceName = result?.lockedSpaceId
44+
? (spaces.find((s) => s.id === result.lockedSpaceId)?.name ?? 'a Space')
45+
: null;
46+
3447
const handleGenerate = async () => {
3548
const trimmed = name.trim();
3649
if (!trimmed) {
@@ -40,7 +53,7 @@ export function RegisterApiKeyClientModal({
4053
setIsSubmitting(true);
4154
setError(null);
4255
try {
43-
const client = await registerApiKeyClient(trimmed);
56+
const client = await registerApiKeyClient(trimmed, lockedSpaceId || null);
4457
setResult(client);
4558
} catch (e) {
4659
setError(e instanceof Error ? e.message : String(e));
@@ -130,6 +143,13 @@ export function RegisterApiKeyClientModal({
130143
<code className="block break-all font-mono text-xs text-[rgb(var(--text))]">
131144
Authorization: Bearer {result.keyPrefix}
132145
</code>
146+
{lockedSpaceName && (
147+
<p className="mt-2 flex items-center gap-1.5 text-xs text-[rgb(var(--muted))]">
148+
<Lock className="h-3.5 w-3.5" />
149+
Locked to <span className="font-medium">{lockedSpaceName}</span> — this key can
150+
only ever reach that Space.
151+
</p>
152+
)}
133153
</div>
134154

135155
<div className="flex justify-end">
@@ -159,6 +179,30 @@ export function RegisterApiKeyClientModal({
159179
/>
160180
</div>
161181

182+
<div>
183+
<label htmlFor="api-key-lock-space" className="mb-1.5 block text-sm font-medium">
184+
Lock to a Space <span className="text-[rgb(var(--muted))]">(optional)</span>
185+
</label>
186+
<select
187+
id="api-key-lock-space"
188+
data-testid="register-api-key-lock-space"
189+
value={lockedSpaceId}
190+
onChange={(e) => setLockedSpaceId(e.target.value)}
191+
className="w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] px-3.5 py-2.5 text-sm transition-all focus:border-[rgb(var(--accent))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--accent))]/40"
192+
>
193+
<option value="">No lock — route by mapping (any Space)</option>
194+
{spaces.map((s) => (
195+
<option key={s.id} value={s.id}>
196+
{s.name}
197+
</option>
198+
))}
199+
</select>
200+
<p className="mt-1.5 text-xs text-[rgb(var(--muted))]">
201+
Locking confines this client to one Space — a leaked key can never reach the
202+
others. Leave unlocked to route it later from the Workspaces tab.
203+
</p>
204+
</div>
205+
162206
<div className="flex items-start gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] p-3.5">
163207
<ShieldCheck className="mt-0.5 h-5 w-5 flex-shrink-0 text-[rgb(var(--accent))]" />
164208
<p className="text-xs text-[rgb(var(--muted))]">

apps/desktop/src/lib/api/gateway.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ export async function revokeOAuthClientFeatureSet(
350350
export interface RegisteredApiKeyClient {
351351
clientId: string;
352352
clientName: string;
353+
lockedSpaceId: string | null;
353354
/** The full key — shown once; afterwards only its hash is kept. */
354355
apiKey: string;
355356
keyPrefix: string;
@@ -366,11 +367,14 @@ export interface ApiKeyInfo {
366367
}
367368

368369
/**
369-
* Register a pre-approved client authenticated by an API key. The returned key
370-
* is shown once and never retrievable again.
370+
* Register a pre-approved client authenticated by an API key, optionally locked
371+
* to a space. The returned key is shown once and never retrievable again.
371372
*/
372-
export async function registerApiKeyClient(name: string): Promise<RegisteredApiKeyClient> {
373-
return invoke('register_api_key_client', { name });
373+
export async function registerApiKeyClient(
374+
name: string,
375+
lockedSpaceId?: string | null
376+
): Promise<RegisteredApiKeyClient> {
377+
return invoke('register_api_key_client', { name, lockedSpaceId: lockedSpaceId ?? null });
374378
}
375379

376380
/** Issue an additional API key for an existing client (rotation). Shown once. */

crates/mcpmux-storage/src/repositories/inbound_client_repository.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,36 @@ impl InboundClientRepository {
707707
Ok(())
708708
}
709709

710+
/// Set (or clear, with `None`) the Space a client is locked to. A locked
711+
/// client is confined to that Space during resolution (see the gateway
712+
/// FeatureSet resolver).
713+
pub async fn set_locked_space(&self, client_id: &str, space_id: Option<&str>) -> Result<()> {
714+
let now = chrono::Utc::now().to_rfc3339();
715+
let db = self.db.lock().await;
716+
let conn = db.connection();
717+
conn.execute(
718+
"UPDATE inbound_clients SET locked_space_id = ?1, updated_at = ?2 WHERE client_id = ?3",
719+
params![space_id, now, client_id],
720+
)?;
721+
Ok(())
722+
}
723+
724+
/// The Space a client is locked to, if any.
725+
pub async fn get_locked_space(&self, client_id: &str) -> Result<Option<String>> {
726+
let db = self.db.lock().await;
727+
let conn = db.connection();
728+
let result = conn.query_row(
729+
"SELECT locked_space_id FROM inbound_clients WHERE client_id = ?1",
730+
params![client_id],
731+
|r| r.get::<_, Option<String>>(0),
732+
);
733+
match result {
734+
Ok(v) => Ok(v),
735+
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
736+
Err(e) => Err(e.into()),
737+
}
738+
}
739+
710740
/// Save a token record
711741
pub async fn save_token(&self, record: &TokenRecord) -> Result<()> {
712742
let db = self.db.lock().await;

0 commit comments

Comments
 (0)