Skip to content
Open
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
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