Skip to content

Commit 303c90f

Browse files
loganjLarry
andcommitted
Two independent-review P2 follow-ups on the D1b hosted-identity repair
(#7489). The settings card's community rows keyed Connect enablement on !identityMismatch alone, so an authenticated account whose identity object was absent (fresh link, or a refresh after unpair/failed rebind) still rendered — and invoked — Connect for its owned communities, since mismatch is false when identity is null. Require a usable bound key in the row's canConnect and guard the invocation callback, so Connect cannot occur without a binding the action would act on; the community list, linking, and delete+rebind recovery stay available. usableBoundIdentityNpub delegated operational usability to the display parser canonicalNpub, which accepts npub strings and padded hex, while all three consumers compared raw lowercased pubkey_hex — so a payload the helper called usable could display the device's own key yet demand a destructive delete/rebind, and an npub stored in the hex field read as a usable binding whenever the local comparison was skipped. Define one hex-only normalized bound-key form (normalizedBoundKeyHex: trim + lowercase + 64 hex chars, npub rejected), use it for usability and both sides of every comparison in all three consumers, and derive the display npubs from the same normalized keys; the general display helper keeps its intentional npub support. Tests: helper unit tests pin npub-in-hex rejection and same-key normalization; the unlinked-account settings regression (already seeding a community) now asserts Connect cannot occur; browser specs add padded same-key (ready, Connect kept — no false rebind) and npub-in-hex (recovery, neutral label, no Connect) cases across the settings card, the create flow, and first-community onboarding, preserving the existing mismatch/rebind recovery assertions. Validation: focused helper suite 8/8; just desktop-typecheck; just desktop-check (formatting fixed narrowly with biome check --write on the touched files only); full desktop unit package 6,477/6,477; affected browser workflows on a fresh dedicated port: add-community + hosted-communities-settings specs 18/18, onboarding integration spec 69/69. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <[email protected]>
1 parent 57579dc commit 303c90f

8 files changed

Lines changed: 647 additions & 53 deletions
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Unit tests for the hosted-identity usable-bound-key predicate.
3+
*
4+
* Hosted surfaces gate connected/readiness/create/connect decisions on
5+
* whether the identity payload carries an authoritative `pubkey_hex` that
6+
* normalizes to a hex key — never on the mere presence of the identity
7+
* object, and never on the display parser's npub-accepting domain. These
8+
* tests pin the predicate's boundary semantics; the E2E specs assert the
9+
* rendered fail-closed states through the real surfaces.
10+
*/
11+
import assert from "node:assert/strict";
12+
import test from "node:test";
13+
14+
import { npubEncode } from "nostr-tools/nip19";
15+
16+
import {
17+
normalizedBoundKeyHex,
18+
usableBoundIdentityNpub,
19+
} from "./hostedCommunityApi.ts";
20+
21+
const BOUND_HEX = "deadbeef".repeat(8);
22+
23+
test("returns the canonical npub for a valid bound hex", () => {
24+
assert.equal(
25+
usableBoundIdentityNpub({ pubkey_hex: BOUND_HEX }),
26+
npubEncode(BOUND_HEX),
27+
);
28+
});
29+
30+
test("normalizes mixed-case bound hex to the canonical npub", () => {
31+
assert.equal(
32+
usableBoundIdentityNpub({ pubkey_hex: BOUND_HEX.toUpperCase() }),
33+
npubEncode(BOUND_HEX),
34+
);
35+
});
36+
37+
test("npub stored in pubkey_hex is never a usable hex binding", () => {
38+
// A checksum-valid npub — even one spelling the very key this device
39+
// signs with — is the display field's domain. Stored in the authoritative
40+
// hex field it is not a hex key, so it must fail closed: never usable,
41+
// never a comparison operand, never a pending-local bypass that reads the
42+
// account as connected because the raw comparison was skipped.
43+
assert.equal(normalizedBoundKeyHex(npubEncode(BOUND_HEX)), null);
44+
assert.equal(
45+
usableBoundIdentityNpub({ pubkey_hex: npubEncode(BOUND_HEX) }),
46+
null,
47+
);
48+
// An all-uppercase NPUB spelling is equally rejected.
49+
assert.equal(
50+
normalizedBoundKeyHex(npubEncode(BOUND_HEX).toUpperCase()),
51+
null,
52+
);
53+
});
54+
55+
test("same-key normalization: padded hex is the key it spells, on both sides", () => {
56+
// Whitespace-padded, mixed-case hex normalizes to the identical value
57+
// usability and comparisons use, so the same key never demands a
58+
// delete/rebind of an identity the device already holds.
59+
const padded = ` ${BOUND_HEX.toUpperCase()} `;
60+
assert.equal(normalizedBoundKeyHex(padded), BOUND_HEX);
61+
assert.equal(normalizedBoundKeyHex(BOUND_HEX), BOUND_HEX);
62+
assert.equal(
63+
usableBoundIdentityNpub({ pubkey_hex: padded }),
64+
npubEncode(BOUND_HEX),
65+
);
66+
});
67+
68+
test("returns null when the identity object is absent", () => {
69+
assert.equal(usableBoundIdentityNpub(null), null);
70+
assert.equal(usableBoundIdentityNpub(undefined), null);
71+
});
72+
73+
test("returns null when the authoritative key field is missing or empty", () => {
74+
// npub-only payload: the server-sent `npub` is an independent, unverified
75+
// spelling and must never substitute for the authoritative key.
76+
assert.equal(usableBoundIdentityNpub({ npub: npubEncode(BOUND_HEX) }), null);
77+
assert.equal(usableBoundIdentityNpub({}), null);
78+
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "" }), null);
79+
});
80+
81+
test("returns null when the authoritative key cannot encode a key", () => {
82+
// Non-hex alphabet.
83+
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "zz".repeat(32) }), null);
84+
// Valid hex alphabet, wrong length for an identity key.
85+
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "f".repeat(63) }), null);
86+
assert.equal(usableBoundIdentityNpub({ pubkey_hex: "f".repeat(65) }), null);
87+
// Whitespace is not an identity key.
88+
assert.equal(usableBoundIdentityNpub({ pubkey_hex: " " }), null);
89+
});
90+
91+
test("the string normalizer rejects the same non-key values directly", () => {
92+
assert.equal(normalizedBoundKeyHex(null), null);
93+
assert.equal(normalizedBoundKeyHex(undefined), null);
94+
assert.equal(normalizedBoundKeyHex("zz".repeat(32)), null);
95+
assert.equal(normalizedBoundKeyHex("f".repeat(63)), null);
96+
assert.equal(normalizedBoundKeyHex("f".repeat(65)), null);
97+
assert.equal(normalizedBoundKeyHex(" "), null);
98+
});

‎desktop/src/features/communities/hostedCommunityApi.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { invoke } from "@tauri-apps/api/core";
22

3+
import { safeNpub } from "@/shared/lib/nostrUtils";
4+
35
export const HOSTED_COMMUNITY_SUFFIX = "communities.buzz.xyz";
46
export const HOSTED_COMMUNITY_LIMIT = 5;
57
export const VALID_HOSTED_COMMUNITY_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -90,6 +92,55 @@ export function hostedCommunityRelayUrl(community: HostedCommunity) {
9092
return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null;
9193
}
9294

95+
const BOUND_KEY_HEX_REGEX = /^[0-9a-f]{64}$/;
96+
97+
/**
98+
* The ONE operational form of a hosted identity's bound key: the trimmed,
99+
* lowercased `pubkey_hex` when that field carries a 64-character hex key,
100+
* null otherwise.
101+
*
102+
* `pubkey_hex` is the authoritative field the mismatch gate and every
103+
* hosted-community operation act on, so its accepted shape is defined here
104+
* once and is hex-only. An `npub` spelling belongs to the server's separate
105+
* display field: one stored in the hex field is not a hex key and fails
106+
* closed here instead of widening the authorization domain beyond what
107+
* comparisons see. Whitespace-padded or mixed-case hex normalizes to the
108+
* identical value on both sides of every comparison, so a key that is the
109+
* same after normalization never reads as a mismatch demanding a
110+
* delete/rebind of an identity the device already holds. Consumers
111+
* normalize the local key through this same function before comparing.
112+
*/
113+
export function normalizedBoundKeyHex(
114+
value: string | null | undefined,
115+
): string | null {
116+
if (!value) return null;
117+
const normalized = value.trim().toLowerCase();
118+
return BOUND_KEY_HEX_REGEX.test(normalized) ? normalized : null;
119+
}
120+
121+
/**
122+
* The canonical npub of the hosted identity's authoritative bound key, or
123+
* null when the payload carries no key the app can act on.
124+
*
125+
* `pubkey_hex` is the field the mismatch gate and every hosted-community
126+
* operation act on, and it is only usable when it normalizes to a hex key
127+
* (`normalizedBoundKeyHex`) — the server-sent `npub` is an independent,
128+
* unverified spelling and is never accepted in its place. The returned npub
129+
* is derived from that same normalized key, so the displayed account
130+
* identity can never disagree with the binding the decisions act on. Hosted
131+
* surfaces derive their connected/readiness/create/connect gates from the
132+
* normalized key instead of identity-object presence, so an identity payload
133+
* without a usable authoritative key (missing, empty, invalid/non-hex,
134+
* wrong-length, npub-only, or an npub stored in the hex field) fails closed
135+
* into recovery rather than presenting the account as connected.
136+
*/
137+
export function usableBoundIdentityNpub(
138+
identity: HostedNostrIdentity | null | undefined,
139+
): string | null {
140+
const hex = normalizedBoundKeyHex(identity?.pubkey_hex);
141+
return hex === null ? null : safeNpub(hex);
142+
}
143+
93144
export function getBuilderlabAuth() {
94145
return invoke<BuilderlabAuth | null>("get_builderlab_auth");
95146
}

‎desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx‎

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ import {
1313
HOSTED_COMMUNITY_SUFFIX,
1414
hostedCommunityErrorMessage,
1515
hostedCommunityRelayUrl,
16-
type BuilderlabAuth,
17-
type HostedCommunity,
18-
type HostedNostrIdentity,
1916
loadHostedCommunityAccount,
17+
normalizedBoundKeyHex,
2018
startBuilderlabLogin,
19+
usableBoundIdentityNpub,
2120
VALID_HOSTED_COMMUNITY_NAME,
21+
type BuilderlabAuth,
22+
type HostedCommunity,
23+
type HostedNostrIdentity,
2224
} from "@/features/communities/hostedCommunityApi";
2325
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
2426
import {
@@ -27,7 +29,8 @@ import {
2729
} from "@/features/channels/ui/channelFormStyles";
2830
import { useIdentityQuery } from "@/shared/api/hooks";
2931
import { cn } from "@/shared/lib/cn";
30-
import { canonicalNpub, UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
32+
import { safeNpub } from "@/shared/lib/nostrUtils";
33+
import { UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
3134
import { Button } from "@/shared/ui/button";
3235
import { Input } from "@/shared/ui/input";
3336

@@ -147,21 +150,31 @@ export function HostedCommunityCreateFlow({
147150
setCommunities([]);
148151
});
149152

150-
const boundPubkey = identity?.pubkey_hex ?? null;
151-
const identityMismatch = Boolean(
152-
identity &&
153-
boundPubkey &&
154-
localPubkey &&
155-
boundPubkey.toLowerCase() !== localPubkey.toLowerCase(),
156-
);
157153
// Identity rows display npubs derived from the same key the mismatch gate
158154
// and recovery actions act on: the account row from the authoritative
159155
// bound `pubkey_hex` — the server-sent `npub` is an independent field that
160156
// nothing on this path proves encodes the same key — and the device row
161157
// from the local key. An unencodable or non-identity-length key renders the
162-
// neutral label instead of leaking raw hex or the unverified npub.
163-
const localNpub = localPubkey ? canonicalNpub(localPubkey) : null;
164-
const boundNpub = boundPubkey ? canonicalNpub(boundPubkey) : null;
158+
// neutral label instead of leaking raw hex or the unverified npub. Both
159+
// keys are compared in the one normalized hex form, so a padded or
160+
// mixed-case spelling of the same key never reads as a mismatch, and an
161+
// npub stored in the hex field is not a key at all.
162+
const boundHex = normalizedBoundKeyHex(identity?.pubkey_hex);
163+
const localHex = normalizedBoundKeyHex(localPubkey);
164+
const localNpub = localHex === null ? null : safeNpub(localHex);
165+
const boundNpub = usableBoundIdentityNpub(identity);
166+
// The account is only usable to this flow when its authoritative
167+
// `pubkey_hex` normalizes to a hex key: without that key the flow cannot
168+
// establish which identity create/connect actions would affect. An
169+
// identity payload without a usable authoritative key therefore counts as
170+
// a mismatch that requires recovery — never as a connected, ready
171+
// account.
172+
const usableBoundIdentity = boundHex !== null;
173+
const identityMismatch = Boolean(
174+
identity &&
175+
(!usableBoundIdentity ||
176+
(boundHex !== null && localHex !== null && boundHex !== localHex)),
177+
);
165178

166179
const switchToDeviceIdentity = () =>
167180
run("Switching identity…", async () => {
@@ -197,7 +210,7 @@ export function HostedCommunityCreateFlow({
197210
normalizedName.length <= 63 &&
198211
VALID_HOSTED_COMMUNITY_NAME.test(normalizedName);
199212
const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT;
200-
const ready = Boolean(auth && identity && !identityMismatch);
213+
const ready = Boolean(auth && usableBoundIdentity && !identityMismatch);
201214

202215
React.useEffect(() => {
203216
if (!ready || !normalizedName || !validName) {
@@ -229,7 +242,13 @@ export function HostedCommunityCreateFlow({
229242

230243
const create = (event: React.FormEvent) => {
231244
event.preventDefault();
232-
if (!validName || !identity || identityMismatch || atCommunityLimit) return;
245+
if (
246+
!validName ||
247+
!usableBoundIdentity ||
248+
identityMismatch ||
249+
atCommunityLimit
250+
)
251+
return;
233252
void run("Creating community…", async () => {
234253
const available = await checkHostedCommunityName(normalizedName);
235254
if (available.error || !available.available) {

‎desktop/src/features/communities/ui/HostedCommunityOnboarding.tsx‎

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,19 @@ import {
1414
HOSTED_COMMUNITY_SUFFIX,
1515
hostedCommunityErrorMessage,
1616
hostedCommunityRelayUrl,
17-
type BuilderlabAuth,
18-
type HostedCommunity,
19-
type HostedNostrIdentity,
2017
loadHostedCommunityAccount,
18+
normalizedBoundKeyHex,
2119
startBuilderlabLogin,
20+
usableBoundIdentityNpub,
2221
VALID_HOSTED_COMMUNITY_NAME,
22+
type BuilderlabAuth,
23+
type HostedCommunity,
24+
type HostedNostrIdentity,
2325
} from "@/features/communities/hostedCommunityApi";
2426
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
2527
import { useIdentityQuery } from "@/shared/api/hooks";
26-
import { canonicalNpub, UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
28+
import { safeNpub } from "@/shared/lib/nostrUtils";
29+
import { UNAVAILABLE_KEY_LABEL } from "@/shared/lib/pubkey";
2730
import { Button } from "@/shared/ui/button";
2831
import { Card } from "@/shared/ui/card";
2932
import { Input } from "@/shared/ui/input";
@@ -195,21 +198,30 @@ export function HostedCommunityOnboarding({
195198
await loadAccount();
196199
});
197200

198-
const boundPubkey = identity?.pubkey_hex ?? null;
199-
const identityMismatch = Boolean(
200-
identity &&
201-
boundPubkey &&
202-
localPubkey &&
203-
boundPubkey.toLowerCase() !== localPubkey.toLowerCase(),
204-
);
205201
// Identity rows display npubs derived from the same key the mismatch gate
206202
// and recovery actions act on: the account row from the authoritative
207203
// bound `pubkey_hex` — the server-sent `npub` is an independent field that
208204
// nothing on this path proves encodes the same key — and the device row
209205
// from the local key. An unencodable or non-identity-length key renders the
210-
// neutral label instead of leaking raw hex or the unverified npub.
211-
const localNpub = localPubkey ? canonicalNpub(localPubkey) : null;
212-
const boundNpub = boundPubkey ? canonicalNpub(boundPubkey) : null;
206+
// neutral label instead of leaking raw hex or the unverified npub. Both
207+
// keys are compared in the one normalized hex form, so a padded or
208+
// mixed-case spelling of the same key never reads as a mismatch, and an
209+
// npub stored in the hex field is not a key at all.
210+
const boundHex = normalizedBoundKeyHex(identity?.pubkey_hex);
211+
const localHex = normalizedBoundKeyHex(localPubkey);
212+
const localNpub = localHex === null ? null : safeNpub(localHex);
213+
const boundNpub = usableBoundIdentityNpub(identity);
214+
// The account only drives this flow when its authoritative `pubkey_hex`
215+
// normalizes to a hex key: without that key the flow cannot establish
216+
// which identity create/connect actions would affect. An identity payload
217+
// without a usable authoritative key therefore counts as a mismatch that
218+
// requires recovery — never as a linked, ready account.
219+
const usableBoundIdentity = boundHex !== null;
220+
const identityMismatch = Boolean(
221+
identity &&
222+
(!usableBoundIdentity ||
223+
(boundHex !== null && localHex !== null && boundHex !== localHex)),
224+
);
213225

214226
const switchToDeviceIdentity = () =>
215227
run("Switching identity…", async () => {
@@ -251,7 +263,12 @@ export function HostedCommunityOnboarding({
251263
const hasCommunities = activeCommunities.length > 0;
252264

253265
React.useEffect(() => {
254-
if (!identity || identityMismatch || !normalizedName || !validName) {
266+
if (
267+
!usableBoundIdentity ||
268+
identityMismatch ||
269+
!normalizedName ||
270+
!validName
271+
) {
255272
setCheckingName(false);
256273
return;
257274
}
@@ -276,7 +293,7 @@ export function HostedCommunityOnboarding({
276293
cancelled = true;
277294
window.clearTimeout(handle);
278295
};
279-
}, [identity, identityMismatch, normalizedName, validName]);
296+
}, [normalizedName, validName, usableBoundIdentity, identityMismatch]);
280297

281298
const connect = (community: HostedCommunity, created = false) => {
282299
const relayUrl = hostedCommunityRelayUrl(community);
@@ -302,7 +319,13 @@ export function HostedCommunityOnboarding({
302319

303320
const create = (event: React.FormEvent) => {
304321
event.preventDefault();
305-
if (!validName || !identity || identityMismatch || atCommunityLimit) return;
322+
if (
323+
!validName ||
324+
!usableBoundIdentity ||
325+
identityMismatch ||
326+
atCommunityLimit
327+
)
328+
return;
306329
void run("Creating community…", async () => {
307330
const available = await checkHostedCommunityName(normalizedName);
308331
if (available.error || !available.available) {
@@ -331,9 +354,11 @@ export function HostedCommunityOnboarding({
331354

332355
const busy = action !== null;
333356
// The account is set up once we're signed in with a linked, matching
334-
// identity. Until then the sign-in / link-identity modal drives the flow and
335-
// the page behind it shows a blurred preview of where communities will land.
336-
const ready = Boolean(auth && identity && !identityMismatch);
357+
// identity — and a link is only usable when its authoritative `pubkey_hex`
358+
// canonicalizes. Until then the sign-in / link-identity modal drives the
359+
// flow and the page behind it shows a blurred preview of where
360+
// communities will land.
361+
const ready = Boolean(auth && usableBoundIdentity && !identityMismatch);
337362
const modalOpen = !loading && !ready;
338363

339364
// Tell the parent once sign-in completes so it can reveal the stage page.

0 commit comments

Comments
 (0)