Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions clients/typescript/fractal-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ WebSockets through the returned Unix tunnel socket. An explicit NodeID bypasses
peer-name lookup. It does not start a TCP proxy or forward trusted-local person
headers. Pairing credentials and signing requirements remain gateway-owned.

The Rust and Node native readers accept exactly `fabric://<NodeID-or-name>/st3-client-v0`
and `fabric://<NodeID-or-name>/fractal-client-v0`. Both dial the exposed
`st3-client-v0` gateway service; endpoint formatting and gateway defaults still use
`st3-client-v0`. Other service names, URI suffixes and trailing line breaks are rejected.

The native TypeScript adapter recognizes canonical hex/base32 NodeID syntax and
leaves Ed25519 key validity to Fabric. Unlike the Rust adapter's exact identity
parser, it does not fall back to peer-name lookup for a syntactically valid but
Expand Down
7 changes: 5 additions & 2 deletions clients/typescript/fractal-client/fabric.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ export class FabricTransportError extends Error {
export class FabricEndpoint {
#target;
constructor(value) {
const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/st3-client-v0$/.exec(value);
// Unlike $, this end assertion also rejects a final line terminator.
const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/(?:st3|fractal)-client-v0(?![\s\S])/.exec(value);
if (!match || match[1] === '.' || match[1] === '..') {
throw new FabricTransportError('invalid-endpoint', 'Expected fabric://<NodeID-or-name>/st3-client-v0');
throw new FabricTransportError('invalid-endpoint', 'Expected fabric://<NodeID-or-name>/st3-client-v0 or fabric://<NodeID-or-name>/fractal-client-v0');
}
this.#target = match[1];
}
Expand Down Expand Up @@ -94,6 +95,8 @@ export class FabricEndpoint {
if (matches.length !== 1) throw new FabricTransportError(matches.length ? 'ambiguous-peer' : 'unknown-peer', 'Choose one unique trusted Fabric peer name or a NodeID');
node = matches[0].node;
}
// Both URI names dial the gateway's exposed st3-client-v0 service;
// the URI formatter and gateway exposure retain their existing default.
const answer = await run(['dial', node, protocol]);
if (typeof answer.socket !== 'string' || !isAbsolute(answer.socket)) {
throw new FabricTransportError('invalid-socket', 'Fabric dial returned no absolute Unix socket path');
Expand Down
25 changes: 25 additions & 0 deletions clients/typescript/fractal-client/fabric.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ test('native Fabric parsing, roundtrip and pre-dial routing guards', async () =>
const endpoint = `fabric://${target}/st3-client-v0`;
assert.equal(FabricEndpoint.parse(endpoint).toString(), endpoint);
}
for (const target of ['DEVBOX', 'dev-box.local', 'a'.repeat(64)]) {
assert.equal(FabricEndpoint.parse(`fabric://${target}/fractal-client-v0`).toString(), `fabric://${target}/st3-client-v0`);
}
for (const service of ['st3-client-v0', 'fractal-client-v0']) {
for (const suffix of ['/', '?x', '#fragment', '\n', '\r\n', '/extra']) {
assert.throws(() => FabricEndpoint.parse(`fabric://box/${service}${suffix}`));
}
assert.throws(() => FabricEndpoint.parse(`prefixfabric://box/${service}`));
}
for (const endpoint of ['fabric:', 'fabric://box', 'fabric://box/other', 'fabric://user@box/st3-client-v0', 'fabric://box/st3-client-v0?token=x']) {
assert.throws(() => FabricEndpoint.parse(endpoint));
}
Expand Down Expand Up @@ -92,6 +101,22 @@ test('trusted names resolve case-insensitively and ambiguity/refusal fail closed
}
});

test('both URI names dial the same exposed gateway service for names and NodeIDs', async () => {
const node = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a';
const socket = path.join(root, 'gateway.sock');
for (const service of ['st3-client-v0', 'fractal-client-v0']) {
for (const target of ['Box', node]) {
const gateway = fixture(`gateway-${service}-${target}`, [{ node, name: 'Box' }], socket);
assert.equal(await native.FabricEndpoint.parse(`fabric://${target}/${service}`).dial(gateway), socket);
const calls = fs.readFileSync(gateway.calls, 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual(calls, [
...(target === 'Box' ? [['peers', '--json']] : []),
['dial', node, 'st3-client-v0', '--json'],
]);
}
}
});

test('fake Fabric carries authenticated HTTP and every client WebSocket stream', { timeout: 10000 }, async t => {
const socket = path.join(root, 'client.sock');
const snapshot = { id: 'snapshot/test', host_id: 'host/test', store_index: 1, projection_version: 'client-projection.v0', created_at: '2026-09-20T00:00:00Z' };
Expand Down
32 changes: 30 additions & 2 deletions crates/fractal-client/src/fabric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use smallclaims::fleet::transport::Fabric;
use crate::ClientError;

const PROTOCOL: &str = "st3-client-v0";
const FRACTAL_PROTOCOL: &str = "fractal-client-v0";
const DIAL_TIMEOUT: Duration = Duration::from_secs(10);

/// A peer's native client protocol, never a proxy URL or arbitrary Fabric protocol.
Expand All @@ -25,10 +26,10 @@ impl fmt::Display for FabricEndpoint {

impl FabricEndpoint {
pub fn parse(value: &str) -> Result<Self, ClientError> {
let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric://<NodeID-or-name>/{PROTOCOL}"));
let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric://<NodeID-or-name>/{PROTOCOL} or fabric://<NodeID-or-name>/{FRACTAL_PROTOCOL}"));
let rest = value.strip_prefix("fabric://").ok_or_else(invalid)?;
let (target, protocol) = rest.split_once('/').ok_or_else(invalid)?;
if protocol != PROTOCOL
if ![PROTOCOL, FRACTAL_PROTOCOL].contains(&protocol)
|| target.is_empty()
|| !target.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')
Expand Down Expand Up @@ -118,6 +119,8 @@ impl FabricEndpoint {
})?;
resolve_peer(&self.target, &peers)?.to_owned()
};
// Both accepted URI names address the gateway's exposed service.
// Keep the legacy dial name while DEFAULT_SERVICE is st3-client-v0.
fabric
.dial_socket(&node, PROTOCOL)
.await
Expand Down Expand Up @@ -160,6 +163,17 @@ mod tests {
let url = format!("fabric://{target}/st3-client-v0");
assert_eq!(FabricEndpoint::parse(&url).unwrap().to_string(), url);
}
for target in ["DEVBOX", "dev-box.local", NODE_ID] {
let endpoint = FabricEndpoint::parse(&format!("fabric://{target}/fractal-client-v0")).unwrap();
assert_eq!(endpoint.to_string(), format!("fabric://{target}/st3-client-v0"));
}
for service in [PROTOCOL, FRACTAL_PROTOCOL] {
for suffix in ["/", "?x", "#fragment", "\n", "\r\n", "/extra"] {
let value = format!("fabric://box/{service}{suffix}");
assert!(FabricEndpoint::parse(&value).is_err(), "{value:?}");
}
assert!(FabricEndpoint::parse(&format!("prefixfabric://box/{service}")).is_err());
}
for value in ["fabric:box/st3-client-v0", "fabric:///st3-client-v0", "fabric://box/st3/pty/x", "fabric://box/st3-client-v0/", "fabric://box/st3-client-v0?x", "fabric://user@box/st3-client-v0", "fabric://box:1/st3-client-v0", "fabric://%62ox/st3-client-v0"] {
assert!(FabricEndpoint::parse(value).is_err(), "{value}");
}
Expand Down Expand Up @@ -207,6 +221,20 @@ mod tests {
Fabric::new(program)
}

#[tokio::test]
async fn both_uri_names_dial_the_exposed_gateway_service() {
let root = tempfile::tempdir().unwrap();
let fabric = fake_fabric(root.path(), &format!(
"case \"$*\" in\n'peers --json') echo '{{\"peers\":[{{\"node\":\"{NODE_ID}\",\"name\":\"Box\"}}]}}';;\n'dial {NODE_ID} st3-client-v0 --json') echo '{{\"socket\":\"/native-tunnel.sock\"}}';;\n*) exit 1;;\nesac"
));
for service in [PROTOCOL, FRACTAL_PROTOCOL] {
for target in ["Box", NODE_ID] {
let endpoint = FabricEndpoint::parse(&format!("fabric://{target}/{service}")).unwrap();
assert_eq!(endpoint.dial_with(&fabric).await.unwrap(), PathBuf::from("/native-tunnel.sock"));
}
}
}

#[tokio::test]
async fn fake_fabric_native_http_roundtrip_and_refusal() {
let root = tempfile::tempdir().unwrap();
Expand Down
3 changes: 2 additions & 1 deletion crates/fractal-kernel/src/fabric_gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ use serde_json::{Value, json};

use crate::fleet::transport::Fabric;

/// The service name `enable` uses when none is given.
/// The service name `enable` uses when none is given. Both native client URI
/// spellings dial this service; accepting `fractal-client-v0` does not change exposure.
pub const DEFAULT_SERVICE: &str = "st3-client-v0";
const PEER_PREFIX: &str = "st-device-";

Expand Down
11 changes: 11 additions & 0 deletions release-notes/fabric-client-uri-accepting-readers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"summary": "Accept both Fabric native client URI service names in Rust and Node clients",
"replay": {"status": "none", "detail": "No claim history or projection changes."},
"schema": {"status": "none", "detail": "No stored schema or gateway setting changes."},
"rules": {"status": "none", "detail": "Trusted peer resolution, pairing authentication and client route restrictions are unchanged."},
"compatibility": {"status": "changed", "detail": "Rust and Node native readers accept exactly st3-client-v0 and fractal-client-v0 URI services. Both dial the exposed st3-client-v0 gateway service. Formatters and gateway defaults still emit st3-client-v0; exposure is unchanged. Other services and URI suffixes remain refused, including trailing line breaks."},
"downtime": {"status": "none", "detail": "No data migration is required."},
"manual": {"status": "none", "detail": "No configuration edits or additional gateway exposures are required."},
"recovery": {"status": "none", "detail": "No saved endpoint or gateway settings are rewritten. Older clients still require the st3-client-v0 URI spelling."}
}
Loading