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
30 changes: 26 additions & 4 deletions sdk/python/src/tinyplace/api/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,46 @@ def __init__(self, http: HttpClient) -> None:
self._http = http

async def get_bundle(self, agent_id: str) -> Json:
return await self._http.get(f"/keys/{encode(agent_id)}/bundle")
return await self._http.get(_key_path(agent_id, "bundle"))

async def health(self, agent_id: str) -> Json:
return await self._http.get_directory_auth_as(
f"/keys/{encode(agent_id)}/health",
_key_path(agent_id, "health"),
agent_id,
)

async def upload_pre_keys(self, agent_id: str, request: JsonDict) -> None:
await self._http.put_directory_auth_as(
f"/keys/{encode(agent_id)}/prekeys",
_key_path(agent_id, "prekeys"),
agent_id,
request,
)

async def rotate_signed_pre_key(self, agent_id: str, request: JsonDict) -> None:
await self._http.put_directory_auth_as(
f"/keys/{encode(agent_id)}/signed-prekey",
_key_path(agent_id, "signed-prekey"),
agent_id,
request,
)


def _key_path(agent_id: str, suffix: str) -> str:
return f"/keys/{_key_route_id(agent_id)}/{suffix}"


def _key_route_id(agent_id: str) -> str:
if _looks_like_base64_public_key(agent_id):
return f"by-public-key/{_base64_to_base64url(agent_id)}"
return encode(agent_id)


def _looks_like_base64_public_key(value: str) -> bool:
if len(value) < 40 or not any(marker in value for marker in "+/="):
return False
return all(char.isalnum() or char in "+/=" for char in value) and (
value.rstrip("=").count("=") == 0
)
Comment on lines +45 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align _looks_like_base64_public_key with the TypeScript heuristic for cross-language parity.

The Python validation is more permissive than the TypeScript looksLikeBase64PublicKey in two ways: char.isalnum() is Unicode-aware (vs ASCII-only [A-Za-z0-9]), and there's no limit on trailing = count (vs {0,2}). A 40+ char string with 3+ trailing = or Unicode letters would pass Python but fail TypeScript, causing the SDKs to route the same input differently — undermining the PR's cross-language routing goal.

Using a regex matching the TypeScript implementation closes both gaps:

🔧 Proposed fix for cross-language parity
 def _looks_like_base64_public_key(value: str) -> bool:
     if len(value) < 40 or not any(marker in value for marker in "+/="):
         return False
-    return all(char.isalnum() or char in "+/=" for char in value) and (
-        value.rstrip("=").count("=") == 0
-    )
+    import re
+    return bool(re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", value))

Move the import re to the top of the file if not already present.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _looks_like_base64_public_key(value: str) -> bool:
if len(value) < 40 or not any(marker in value for marker in "+/="):
return False
return all(char.isalnum() or char in "+/=" for char in value) and (
value.rstrip("=").count("=") == 0
)
def _looks_like_base64_public_key(value: str) -> bool:
if len(value) < 40 or not any(marker in value for marker in "+/="):
return False
import re
return bool(re.fullmatch(r"[A-Za-z0-9+/]+={0,2}", value))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/python/src/tinyplace/api/keys.py` around lines 45 - 50, Update
_looks_like_base64_public_key to match the TypeScript heuristic exactly: use an
ASCII-only regex for the allowed base64 characters and permit zero to two
trailing padding characters, while preserving the existing minimum length and
required marker checks. Move or add the re import at module scope as needed.



def _base64_to_base64url(value: str) -> str:
return value.replace("+", "-").replace("/", "_").rstrip("=")
28 changes: 28 additions & 0 deletions sdk/python/tests/test_api_namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,34 @@ async def test_keys_docs_and_search_routes() -> None:
assert session.requests[1]["headers"]["X-Agent-ID"] == "@agent"


async def test_keys_routes_base64_public_keys_as_url_safe_segments() -> None:
signer = LocalSigner.from_seed(bytes([25]) * 32)
session = FakeSession([FakeResponse(200, {"ok": True}) for _ in range(4)])
client = TinyPlaceClient(
base_url="https://api.example.test",
signer=signer,
session=session, # type: ignore[arg-type]
)
agent_id = "h71YEo+jgSQJ8bG0msq/ES3I4A0K9oKRA5Eqq3yY4Q8="

await client.keys.get_bundle(agent_id)
await client.keys.health(agent_id)
await client.keys.upload_pre_keys(agent_id, {"preKeys": []})
await client.keys.rotate_signed_pre_key(agent_id, {"signedPreKey": {}})

paths = [
request["url"].replace("https://api.example.test", "")
for request in session.requests
]
assert paths == [
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/bundle",
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/health",
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/prekeys",
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/signed-prekey",
]
assert session.requests[1]["headers"]["X-Agent-ID"] == agent_id


async def test_directory_routes() -> None:
session = FakeSession([FakeResponse(200, {"ok": True}) for _ in range(9)])
signer = LocalSigner.from_seed(bytes([23]) * 32)
Expand Down
33 changes: 27 additions & 6 deletions sdk/typescript/src/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,19 @@ export class KeysApi {
constructor(private readonly http: HttpClient) {}

getBundle(agentId: string): Promise<KeyBundle> {
return this.http.get<KeyBundle>(
`/keys/${encodeURIComponent(agentId)}/bundle`,
);
return this.http.get<KeyBundle>(keyPath(agentId, "bundle"));
}

health(agentId: string): Promise<KeyHealth> {
return this.http.getDirectoryAuthAs<KeyHealth>(
`/keys/${encodeURIComponent(agentId)}/health`,
keyPath(agentId, "health"),
agentId,
);
}

uploadPreKeys(agentId: string, request: PreKeysRequest): Promise<void> {
return this.http.putDirectoryAuthAs<void>(
`/keys/${encodeURIComponent(agentId)}/prekeys`,
keyPath(agentId, "prekeys"),
agentId,
request,
);
Expand All @@ -35,9 +33,32 @@ export class KeysApi {
request: SignedPreKeyRequest,
): Promise<void> {
return this.http.putDirectoryAuthAs<void>(
`/keys/${encodeURIComponent(agentId)}/signed-prekey`,
keyPath(agentId, "signed-prekey"),
agentId,
request,
);
}
}

function keyPath(agentId: string, suffix: string): string {
return `/keys/${keyRouteId(agentId)}/${suffix}`;
}

function keyRouteId(agentId: string): string {
if (looksLikeBase64PublicKey(agentId)) {
return `by-public-key/${base64ToBase64Url(agentId)}`;
}
return encodeURIComponent(agentId);
}

function looksLikeBase64PublicKey(value: string): boolean {
return (
value.length >= 40 &&
/[+/=]/.test(value) &&
/^[A-Za-z0-9+/]+={0,2}$/.test(value)
);
}

function base64ToBase64Url(value: string): string {
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
19 changes: 16 additions & 3 deletions sdk/typescript/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
export type TinyPlaceErrorCode =
| "payment_required"
| "auth_invalid"
| "admin_not_configured"
| "handle_taken"
| "not_found"
| "rate_limited"
Expand All @@ -33,6 +34,7 @@ export type TinyPlaceErrorCode =
export const TINYPLACE_ERROR_CODES: ReadonlyArray<TinyPlaceErrorCode> = [
"payment_required",
"auth_invalid",
"admin_not_configured",
"handle_taken",
"not_found",
"rate_limited",
Expand Down Expand Up @@ -70,6 +72,8 @@ const HINTS: Record<TinyPlaceErrorCode, string> = {
"Payment required (x402 challenge). Settle it (e.g. `tinyplace pay --data '<paymentRequired>'`), then retry the original call.",
auth_invalid:
"Authentication rejected. Confirm your signing key matches the registered identity (TINYPLACE_SECRET_KEY); if you have no identity yet, run `tinyplace init` then `tinyplace register`.",
admin_not_configured:
"Admin approval is not configured. Stop retrying and configure an admin signing route or ask platform support to approve/recover the bounty.",
handle_taken: "That @handle is already claimed — choose a different handle.",
not_found:
"Not found. Re-check the id or @handle; resolve a handle first with `tinyplace resolve @name`.",
Expand All @@ -90,6 +94,7 @@ const HINTS: Record<TinyPlaceErrorCode, string> = {
const RETRYABLE: Record<TinyPlaceErrorCode, boolean> = {
payment_required: false,
auth_invalid: false,
admin_not_configured: false,
handle_taken: false,
not_found: false,
rate_limited: true,
Expand All @@ -104,6 +109,8 @@ const RETRYABLE: Record<TinyPlaceErrorCode, boolean> = {
const WHEN: Record<TinyPlaceErrorCode, string> = {
payment_required: "HTTP 402, or a response carrying an x402 payment challenge.",
auth_invalid: "HTTP 401/403 — the request signature or session was rejected.",
admin_not_configured:
"The bounty approval endpoint reports that admin approval is not configured.",
handle_taken:
"HTTP 409 (or a body) indicating the @handle/username is already claimed.",
not_found: "HTTP 404 — the id or @handle does not exist.",
Expand Down Expand Up @@ -158,11 +165,14 @@ function deriveCode(error: unknown): TinyPlaceErrorCode {
return "payment_required";
}

// 2. Local SDK-thrown errors (no HTTP status).
// 2. Configuration failures that need operator action, not blind retries.
if (ADMIN_APPROVAL_NOT_CONFIGURED.test(text)) return "admin_not_configured";

// 3. Local SDK-thrown errors (no HTTP status).
if (name === "TinyPlaceValidationError") return "validation";
if (status === undefined && SIGNER_REQUIRED.test(text)) return "no_signer";

// 3. Status-driven classification.
// 4. Status-driven classification.
if (status !== undefined) {
if (status === 0) return "transient";
if (status === 429) return "rate_limited";
Expand All @@ -180,14 +190,17 @@ function deriveCode(error: unknown): TinyPlaceErrorCode {
if (status >= 400 && HANDLE_TAKEN.test(text)) return "handle_taken";
}

// 4. Non-HTTP fallbacks.
// 5. Non-HTTP fallbacks.
if (HANDLE_TAKEN.test(text)) return "handle_taken";
return "unknown";
}

const SIGNER_REQUIRED =
/requires? (a )?(signing key|signer)|needs? a (wallet|signer)|secret_key|no signer/i;

const ADMIN_APPROVAL_NOT_CONFIGURED =
/admin approval is not configured|approval.*admin.*not configured/i;

const HANDLE_TAKEN =
/(handle|username|name)[^.]*(taken|exists|already|registered|claimed|conflict)|(taken|exists|already|registered|claimed)[^.]*(handle|username)/i;

Expand Down
45 changes: 45 additions & 0 deletions sdk/typescript/tests/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,49 @@ describe("KeysApi", () => {
"https://example.test/keys/%40agent/signed-prekey",
);
});

it("routes base64 public keys through the URL-safe key route", async () => {
const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(59));
const agentId = "h71YEo+jgSQJ8bG0msq/ES3I4A0K9oKRA5Eqq3yY4Q8=";
const requests: Array<Request> = [];
const client = new TinyPlaceClient({
baseUrl: "https://example.test",
signer,
fetch: async (input, init) => {
const request = new Request(input, init);
requests.push(request);
if (request.method === "GET") {
return Response.json({
agentId,
oneTimePreKeyCount: 0,
lowOneTimePreKeys: true,
});
}
return new Response(null, { status: 204 });
},
});

await client.keys.getBundle(agentId);
await client.keys.health(agentId);
await client.keys.uploadPreKeys(agentId, {
identityKey: signer.publicKeyBase64,
preKeys: [{ keyId: "opk_1", publicKey: "pub", signature: "sig" }],
});
await client.keys.rotateSignedPreKey(agentId, {
identityKey: signer.publicKeyBase64,
signedPreKey: { keyId: "spk_1", publicKey: "pub", signature: "sig" },
});

expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/bundle",
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/health",
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/prekeys",
"/keys/by-public-key/h71YEo-jgSQJ8bG0msq_ES3I4A0K9oKRA5Eqq3yY4Q8/signed-prekey",
]);

for (const request of requests.slice(1)) {
expect(request.headers.get("X-Agent-ID")).toBe(agentId);
expect(request.headers.get("X-TinyPlace-Signature")).toBeTruthy();
}
});
});
7 changes: 7 additions & 0 deletions sdk/typescript/tests/tinyplace-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ describe("TinyPlaceError self-classification", () => {
expect(error.paymentRequired?.payment.amount).toBe("1000");
});

it("classifies missing admin approval configuration as operator action", () => {
const error = new TinyPlaceError(503, "admin approval is not configured");
expect(error.code).toBe("admin_not_configured");
expect(error.retryable).toBe(false);
expect(error.hint).toMatch(/configure an admin signing route/i);
});

it("parses the challenge from the standard x402 v2 accepts[] array", () => {
const error = new TinyPlaceError(402, {
error: "payment required",
Expand Down
Loading