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
29 changes: 29 additions & 0 deletions sdk/rust/src/api/users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ fn user_profile_signature_payload(crypto_id: &str, update: &UserProfileUpdate) -
"displayName": or_null(&update.display_name),
"harnessKey": or_null(&update.harness_key),
"link": or_null(&update.link),
"private": or_null(&update.private),
"tags": arr_or_null(&update.tags),
}),
)
Expand Down Expand Up @@ -173,3 +174,31 @@ fn user_email_confirm_signature_payload(
}),
)
}

#[cfg(test)]
mod tests {
use super::*;

// Regression: the backend's canonical user.profile payload includes the
// wallet-level `private` flag. If the SDK omits it, the signature never
// verifies and profile saves fail with HTTP 401.
#[test]
fn profile_payload_includes_private_flag() {
let default_payload =
user_profile_signature_payload("cid123", &UserProfileUpdate::default());
assert!(
default_payload.contains("\"private\":null"),
"default payload must carry private as null: {default_payload}"
);

let update = UserProfileUpdate {
private: Some(true),
..Default::default()
};
let set_payload = user_profile_signature_payload("cid123", &update);
assert!(
set_payload.contains("\"private\":true"),
"payload must carry the set private flag: {set_payload}"
);
}
}
3 changes: 3 additions & 0 deletions sdk/rust/src/types/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ pub struct UserProfileUpdate {
pub link: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tags: Option<Vec<String>>,
/// Wallet-level privacy flag; omit to leave the current value unchanged.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub private: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub signature: Option<String>,
}
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ function userProfileSignaturePayload(
displayName: update.displayName ?? null,
harnessKey: update.harnessKey ?? null,
link: update.link ?? null,
private: update.private ?? null,
tags: update.tags ?? null,
});
}
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export interface UserProfileUpdate {
harnessKey?: string;
link?: string;
tags?: Array<string>;
/** Wallet-level privacy flag; omit to leave the current value unchanged. */
private?: boolean;
signature?: string;
}

Expand Down
54 changes: 53 additions & 1 deletion sdk/typescript/tests/users.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ed25519 } from "@noble/curves/ed25519.js";
import { describe, expect, it } from "vitest";
import { LocalSigner, TinyPlaceClient } from "../src/index.js";
import { canonicalPayload, LocalSigner, TinyPlaceClient } from "../src/index.js";

describe("UsersApi", () => {
it("fetches a wallet profile by cryptoId", async () => {
Expand Down Expand Up @@ -81,6 +82,57 @@ describe("UsersApi", () => {
expect(body.signature!.length).toBeGreaterThan(0);
});

it("includes the private flag in the signed user.profile payload", async () => {
// Regression: the backend's canonical user.profile payload includes the
// wallet-level `private` flag. If the SDK omits it from the signed payload,
// the signature never verifies and profile saves fail with HTTP 401.
const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(9));
let captured: Request | undefined;
const client = new TinyPlaceClient({
baseUrl: "https://example.test",
signer,
fetch: async (input, init) => {
captured = new Request(input, init);
return Response.json({
cryptoId: "WalletCrypto222",
actorType: "agent",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-02T00:00:00Z",
});
},
});

await client.users.updateProfile("WalletCrypto222", {
displayName: "Ada",
private: true,
});

const body = (await captured!.json()) as { signature: string };
// Freshness signature: v1:<b64url(ts)>:<b64url(nonce)>:<b64(sig)>.
const [version, tsPart, noncePart, sigPart] = body.signature.split(":");
expect(version).toBe("v1");
const timestamp = Buffer.from(tsPart!, "base64url").toString("utf8");
const nonce = Buffer.from(noncePart!, "base64url").toString("utf8");
const signature = new Uint8Array(Buffer.from(sigPart!, "base64"));

// Reconstruct the exact canonical payload the client must have signed,
// including `private`, and verify the signature against it.
const payload = canonicalPayload("user.profile", {
actorType: null,
avatarEmail: null,
bio: null,
cryptoId: "WalletCrypto222",
displayName: "Ada",
harnessKey: null,
link: null,
private: true,
tags: null,
});
const message = new TextEncoder().encode(`${payload}\n${timestamp}\n${nonce}`);
const publicKey = new Uint8Array(Buffer.from(signer.publicKeyBase64, "base64"));
expect(ed25519.verify(signature, message, publicKey)).toBe(true);
});

it("starts and confirms email verification with signed wallet-scoped payloads", async () => {
const signer = await LocalSigner.fromSeed(new Uint8Array(32).fill(8));
const requests: Array<Request> = [];
Expand Down
Loading