Skip to content
Open
4 changes: 4 additions & 0 deletions e2e/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
#![warn(unused_crate_dependencies)]
// E2E tests
#[cfg(test)]
#[allow(
deprecated,
reason = "E2E tests intentionally cover legacy cookie compatibility alongside grant flows"
)]
mod tests;
21 changes: 11 additions & 10 deletions e2e/src/tests/storage/legacy_put_get_delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ use super::*;

#[tokio::test]
#[pubky_testnet::test]
#[allow(
deprecated,
reason = "This test covers legacy cookie and routing compatibility"
)]
async fn legacy_and_storage_routes_interoperate() {
let testnet = build_full_testnet().await;
let server = testnet.homeserver_app();
Expand All @@ -14,22 +18,16 @@ async fn legacy_and_storage_routes_interoperate() {
let cookie_secret = session.as_cookie().unwrap().export_secret().unwrap();
let (cookie_name, cookie_value) = cookie_secret.split_once(':').unwrap();
let cookie = format!("{cookie_name}={cookie_value}");
let legacy_url = format!(
"{}pub/foo.txt?pubky-host={}",
server.icann_http_url(),
session.public_key().z32()
);
let storage_url = format!(
"{}storage/{}/pub/foo.txt",
server.icann_http_url(),
session.public_key().z32()
);
let owner = session.public_key().z32();
let legacy_url = format!("{}pub/foo.txt", server.icann_http_url());
let storage_url = format!("{}storage/{}/pub/foo.txt", server.icann_http_url(), owner);

// A legacy write can be read and deleted through `/storage`.
let response = session
.client()
.request(Method::PUT, &legacy_url)
.header("Host", "non.pubky.host")
.header("pubky-host", &owner)
.header("Cookie", &cookie)
.body(vec![0, 1, 2, 3, 4])
.send()
Expand Down Expand Up @@ -66,6 +64,7 @@ async fn legacy_and_storage_routes_interoperate() {
.client()
.request(Method::GET, &legacy_url)
.header("Host", "non.pubky.host")
.header("pubky-host", &owner)
.send()
.await
.unwrap();
Expand All @@ -86,6 +85,7 @@ async fn legacy_and_storage_routes_interoperate() {
.client()
.request(Method::GET, &legacy_url)
.header("Host", "non.pubky.host")
.header("pubky-host", &owner)
.send()
.await
.unwrap();
Expand All @@ -99,6 +99,7 @@ async fn legacy_and_storage_routes_interoperate() {
.client()
.request(Method::DELETE, &legacy_url)
.header("Host", "non.pubky.host")
.header("pubky-host", &owner)
.header("Cookie", &cookie)
.send()
.await
Expand Down
28 changes: 27 additions & 1 deletion pubky-common/src/auth/jws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ impl RandomId {

/// Parse and validate an existing ID string.
///
/// Must be non-empty and at most 22 characters.
/// Must be non-empty, at most 22 characters, and contain only base64url characters.
pub fn parse(s: &str) -> Result<Self, Error> {
if s.is_empty() {
return Err(Error::InvalidFormat("RandomId must not be empty"));
Expand All @@ -124,6 +124,14 @@ impl RandomId {
"RandomId must be at most 22 characters",
));
}
if !s
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(Error::InvalidFormat(
"RandomId must contain only base64url characters",
));
}
Ok(Self(s.to_string()))
}

Expand Down Expand Up @@ -262,9 +270,27 @@ mod tests {
#[test]
fn random_id_parse_valid() {
RandomId::parse("abc123").unwrap();
RandomId::parse("AZaz09-_").unwrap();
RandomId::parse("a").unwrap(); // min length
}

#[test]
fn random_id_parse_rejects_non_base64url_characters() {
for value in [
".",
"..",
"../../../pub/a.txt",
"a/b",
"a?b",
"a#b",
"a+b",
"a=b",
"a b",
] {
assert!(RandomId::parse(value).is_err(), "accepted {value:?}");
}
}

#[test]
fn random_id_parse_rejects_empty() {
assert!(RandomId::parse("").is_err());
Expand Down
6 changes: 6 additions & 0 deletions pubky-common/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ pub mod storage {
pub const PUBLIC_ROOT: &str = "/pub/";
}

/// Features advertised by the homeserver client API.
pub mod features {
/// Homeserver supports storage URLs containing the resource owner in the path.
pub const PATH_ADDRESSED_STORAGE: &str = "path-addressed-storage";
}

/// Local test network's hardcoded port numbers for local development.
pub mod testnet_ports {
/// The local test network's hardcoded DHT bootstrapping node's port number.
Expand Down
4 changes: 2 additions & 2 deletions pubky-homeserver/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ See [config.sample.toml](config.sample.toml) for all configuration options.

When an SDK change requires homeserver behavior that older versions do not
support, such as a new endpoint, add a stable feature identifier to the client
`GET /info` response. SDKs must check that identifier before using the new
behavior.
`GET /info` response. SDKs must check it before using the new behavior and
ignore unknown identifiers.

## API Specifications

Expand Down
3 changes: 2 additions & 1 deletion pubky-homeserver/openapi-client.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ paths:
schema:
"$ref": "#/components/schemas/ClientInfoResponse"
example:
features: []
features:
- path-addressed-storage
"/signup_tokens/{token}":
get:
tags:
Expand Down
6 changes: 4 additions & 2 deletions pubky-homeserver/src/client_server/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ mod tests {

#[tokio::test]
#[pubky_test_utils::test]
async fn info_is_public_and_reports_no_features() {
async fn info_is_public_and_reports_features() {
let mut config = ConfigToml::minimal_test_config();
config.drive.rate_limits = vec![PathLimit {
path: GlobPattern::new("/info"),
Expand All @@ -333,7 +333,9 @@ mod tests {
response.assert_status(StatusCode::OK);
response.assert_header(header::CONTENT_TYPE, "application/json");
response.assert_header(header::CACHE_CONTROL, "no-store");
response.assert_json(&serde_json::json!({ "features": [] }));
response.assert_json(&serde_json::json!({
"features": ["path-addressed-storage"]
}));
}

async fn signup_cookie(server: &TestServer, keypair: &Keypair) -> String {
Expand Down
3 changes: 2 additions & 1 deletion pubky-homeserver/src/client_server/routes/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ use axum::{
response::IntoResponse,
Json,
};
use pubky_common::constants::features::PATH_ADDRESSED_STORAGE;
use serde::Serialize;

const FEATURES: &[&str] = &[];
const FEATURES: &[&str] = &[PATH_ADDRESSED_STORAGE];

#[derive(Serialize)]
struct InfoResponse {
Expand Down
14 changes: 9 additions & 5 deletions pubky-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ let caps = Capabilities::builder()
.write("/pub/example.com/")
.expect("static scope is canonical")
.finish();
let flow = pubky.start_cookie_auth_flow(&caps, AuthFlowKind::signin())?;
let flow = pubky.start_grant_auth_flow(
&caps,
AuthFlowKind::signin(),
ClientId::new("my-cool-app").unwrap(),
)?;
println!("Scan to sign in: {}", flow.authorization_url());
let app_session = flow.await_approval().await?;

Expand All @@ -71,9 +75,9 @@ println!("Your current homeserver: {:?}", resolved);
`PublicKey` has two string representations:

- **Display format**: `pubky<z32>` (used for logs/UI and human-facing identifiers).
- **Transport/storage format**: raw `z32` (used for hostnames, headers, query params, serde/JSON, and database storage).
- **Transport/storage format**: raw `z32` (used for hostnames, storage owner path segments, legacy headers, query params, serde/JSON, and database storage).

Use `.z32()` whenever you are building hostnames or transport values (for example `_pubky.<z32>` or the `pubky-host` header). Use `Display`/`.to_string()` when you want the prefixed identifier for people.
Use `.z32()` whenever you are building hostnames or transport values (for example `_pubky.<z32>`, `/storage/<z32>/...`, or the legacy `pubky-host` header). Use `Display`/`.to_string()` when you want the prefixed identifier for people.

### Reuse a single facade across your app

Expand Down Expand Up @@ -168,7 +172,7 @@ Need to feed a public resource into a raw HTTP client? Use [`resolve_pubky`] to
let url = resolve_pubky("pubkyoperrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG")?;
assert_eq!(
url.as_str(),
"https://_pubky.operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG"
"https://_pubky.operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/storage/operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG"
);
# Ok(())
# }
Expand Down Expand Up @@ -203,7 +207,7 @@ Request an authorization URL and await approval.

**Typical usage:**

1. Start an auth flow with `pubky.start_grant_auth_flow(&caps, ..)` (or `pubky.start_cookie_auth_flow(&caps, ..)` for the cookie variant). You can also use `PubkyGrantAuthFlow::builder()` / `PubkyCookieAuthFlow::builder()` to set a custom relay.
1. Start an auth flow with `pubky.start_grant_auth_flow(&caps, ..)`. You can also use `PubkyGrantAuthFlow::builder()` to set a custom relay. The deprecated cookie flow remains available only for compatibility.
2. Show `authorization_url()` (QR/deeplink) to the signing device (e.g., [Pubky Ring](https://github.com/pubky/pubky-ring) — [iOS](https://apps.apple.com/om/app/pubky-ring/id6739356756) / [Android](https://play.google.com/store/apps/details?id=to.pubky.ring)).
3. Await `await_approval()` to obtain a session-bound `PubkySession`, or `await_credential()` for a raw `GrantCredential`/`CookieCredential` that you can persist, inspect, or lift into a session later via `PubkySession::from_{grant,cookie}_credential`.

Expand Down
2 changes: 1 addition & 1 deletion pubky-sdk/bindings/js/pkg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ import { resolvePubky } from "@synonymdev/pubky";
const identifier =
"pubkyoperrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG";
const url = resolvePubky(identifier);
// -> "https://_pubky.operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG"
// -> "https://_pubky.operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/storage/operrr8wsbpr3ue9d4qj41ge1kcc6r7fdiy6o3ugjrrhi4y77rdo/pub/pubky.app/posts/0033X02JAN0SG"
```

Both `pubky<pk>/…` (preferred) and `pubky://<pk>/…` resolve to the same HTTPS endpoint.
Expand Down
142 changes: 141 additions & 1 deletion pubky-sdk/bindings/js/pkg/tests/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import test from "tape";
import { Client } from "../index.js";
import { Client, Keypair } from "../index.js";
import type { PubkyError } from "../index.js";
import { hasNetwork } from "./utils.js";

Expand Down Expand Up @@ -138,6 +138,146 @@ test("fetch merges plain object headers", async (t) => {
t.end();
});

test("ordinary HTTP storage paths remain untouched", async (t) => {
const client = Client.testnet();
const owner = Keypair.random().publicKey.z32();
const originalFetch = globalThis.fetch;
const requests: Request[] = [];

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
requests.push(request);
const response = new Response(null, { status: 204 });
Object.defineProperty(response, "url", { value: request.url });
return response;
}) as typeof fetch;

try {
await client.fetch("https://example.com/storage/images/logo.png");
await client.fetch(`https://example.com/storage/${owner}/pub/file.txt`);
} finally {
globalThis.fetch = originalFetch;
}

t.equal(requests.length, 2, "only the requested resources were fetched");
t.equal(
requests[0].url,
"https://example.com/storage/images/logo.png",
"ordinary storage path preserved",
);
t.equal(
requests[1].url,
`https://example.com/storage/${owner}/pub/file.txt`,
"z32 storage segment preserved on an ordinary host",
);
t.equal(requests[0].headers.get("pubky-host"), null, "pubky-host omitted");
t.equal(requests[1].headers.get("pubky-host"), null, "pubky-host omitted for z32 path");
t.end();
});

test("path-addressed storage omits pubky-host", async (t) => {
const client = Client.testnet();
const owner = Keypair.random().publicKey.z32();
const originalFetch = globalThis.fetch;
let infoRequest: Request | undefined;
let seenRequest: Request | undefined;
let storageRequests = 0;

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
const path = new URL(request.url).pathname;

if (path === "/info") {
infoRequest = request;
const response = new Response(JSON.stringify({ features: ["path-addressed-storage"] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
Object.defineProperty(response, "url", { value: request.url });
return response;
} else if (path.startsWith("/storage/")) {
seenRequest = request;
storageRequests += 1;
const response = new Response(null, { status: 404 });
Object.defineProperty(response, "url", { value: request.url });
return response;
}

return originalFetch(input as RequestInfo | URL, init);
}) as typeof fetch;

try {
await client.fetch(
`https://${TLD}/storage/${owner}/pub/missing.txt?cursor=hello%20world`,
);
} finally {
globalThis.fetch = originalFetch;
}

t.ok(infoRequest, "feature discovery requested /info");
t.equal(infoRequest!.credentials, "omit", "feature discovery omitted credentials");
t.equal(infoRequest!.headers.get("pubky-host"), null, "feature discovery omitted pubky-host");
t.ok(seenRequest, "fetch preserved the path-addressed storage request");
t.equal(storageRequests, 1, "storage response did not trigger a fallback retry");
t.equal(seenRequest!.headers.get("pubky-host"), null, "pubky-host omitted");
t.equal(seenRequest!.credentials, "include", "cookie credentials preserved");
t.equal(
new URL(seenRequest!.url).search,
"?cursor=hello%20world",
"query parameters preserved",
);
t.end();
});

test("storage falls back when homeserver info is unavailable", async (t) => {
const client = Client.testnet();
const owner = Keypair.random().publicKey.z32();
const originalFetch = globalThis.fetch;
let infoRequest: Request | undefined;
let storageRequest: Request | undefined;

globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init);
const path = new URL(request.url).pathname;

if (path === "/info") {
infoRequest = request;
const response = new Response(null, { status: 404 });
Object.defineProperty(response, "url", { value: request.url });
return response;
}
if (path === "/pub/fallback.txt") {
storageRequest = request;
const response = new Response(null, { status: 404 });
Object.defineProperty(response, "url", { value: request.url });
return response;
}

return originalFetch(input as RequestInfo | URL, init);
}) as typeof fetch;

try {
await client.fetch(
`https://${TLD}/storage/${owner}/pub/fallback.txt?cursor=hello%20world`,
);
} finally {
globalThis.fetch = originalFetch;
}

t.ok(infoRequest, "feature discovery requested /info");
t.equal(infoRequest!.credentials, "omit", "feature discovery omitted credentials");
t.equal(infoRequest!.headers.get("pubky-host"), null, "feature discovery omitted pubky-host");
t.ok(storageRequest, "storage used the legacy path");
t.equal(storageRequest!.headers.get("pubky-host"), owner, "legacy owner header attached");
t.equal(storageRequest!.credentials, "include", "storage credentials preserved");
t.equal(
new URL(storageRequest!.url).search,
"?cursor=hello%20world",
"query parameters preserved",
);
t.end();
});

test("fetch failed", async (t) => {
const client = Client.testnet();

Expand Down
Loading
Loading