diff --git a/js/app/components/settings/branding-admin.tsx b/js/app/components/settings/branding-admin.tsx
index 09723701c..f8d4383f0 100644
--- a/js/app/components/settings/branding-admin.tsx
+++ b/js/app/components/settings/branding-admin.tsx
@@ -1054,6 +1054,27 @@ export function BrandingAdmin() {
{t("branding-reset")}
+
+ {t("branding-verify-bluesky-description")}
+
+
+ v === "off"
+ ? deleteBlob("verifyBluesky")
+ : uploadText("verifyBluesky", v)
+ }
+ />
{t("branding-verify-url-description")}
diff --git a/js/components/src/components/chat/verified-badge.tsx b/js/components/src/components/chat/verified-badge.tsx
index f4fea010c..234ef3ec4 100644
--- a/js/components/src/components/chat/verified-badge.tsx
+++ b/js/components/src/components/chat/verified-badge.tsx
@@ -29,6 +29,10 @@ export function VerifiedCheck({
const BLUESKY_BLUE = "#1185fe"; // token-ok: Bluesky's verification color
+/** The verifier DID the node files Bluesky's blue check under when the
+ * branding key verifyBluesky is on (pkg/atproto/appview_verification.go). */
+export const BLUESKY_APPVIEW_ISSUER = "did:web:api.bsky.app";
+
function decodeDataUrlText(dataUrl: string): string | null {
const comma = dataUrl.indexOf(",");
if (comma < 0) return null;
@@ -86,7 +90,8 @@ function NodeVerifiedIcon({ size }: { size: number }) {
* - the node's trusted verifiers (branding key verifierDids), which the
* node reports on the message author itself — the branded badge;
* - the public app view's verification (e.g. Bluesky's), read from the
- * profile cache — the network's blue check.
+ * profile cache, or mirrored by the node under Bluesky's app view DID
+ * when verifyBluesky is on — the network's blue check.
* A user verified both ways gets the node's badge only.
*/
export function VerifiedBadge({
@@ -95,13 +100,26 @@ export function VerifiedBadge({
size = 16,
style,
}: {
- author: { verification?: { verifiedStatus?: string } | null };
+ author: {
+ verification?: {
+ verifiedStatus?: string;
+ verifications?: { issuer: string }[];
+ } | null;
+ };
profile?: AppBskyActorDefs.ProfileViewDetailed | null;
size?: number;
style?: any;
}) {
- const nodeVerified = author.verification?.verifiedStatus === "valid";
- const networkVerified = profile?.verification?.verifiedStatus === "valid";
+ const nodeIssuers = author.verification?.verifications ?? [];
+ const nodeValid = author.verification?.verifiedStatus === "valid";
+ // Rows filed under Bluesky's app view are Bluesky's check, not ours.
+ const nodeVerified =
+ nodeValid &&
+ (nodeIssuers.length === 0 ||
+ nodeIssuers.some((v) => v.issuer !== BLUESKY_APPVIEW_ISSUER));
+ const networkVerified =
+ profile?.verification?.verifiedStatus === "valid" ||
+ (nodeValid && !nodeVerified);
if (!nodeVerified && !networkVerified) return null;
return (
, so the app knows where to send people back. Leave empty to append nothing.
branding-verify-url-description = Where a signed-in but unverified user goes to get verified: the "Verify now" link under the composer when chat is verified-only. Unset shows no link.
branding-verified-only-message-description = What the composer says when chat is verified-only and the viewer can't write, and the label of the verify link beside it. Signed-out viewers get a sign-in link instead.
diff --git a/js/i18n/public/locales/en-US/settings.json b/js/i18n/public/locales/en-US/settings.json
index 1d692d32a..9723c9866 100644
--- a/js/i18n/public/locales/en-US/settings.json
+++ b/js/i18n/public/locales/en-US/settings.json
@@ -252,6 +252,9 @@
"branding-verifiers-description": "DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless.",
"branding-labeler-description": "Or a labeler and the label values that mean verified on your network: the labeler's DID, then the labels, comma-separated, with a trailing * for a prefix (e.g. verified-*). Accounts carrying one of those labels count as verified here, next to any verifiers above.",
"branding-verify-appview-description": "Or an app view whose getProfile carries your network's own verification field: its URL, the field name (default wsocialVerified) and the values that count, comma-separated, or * for any value. The node asks it about each chat author on first sight and re-checks every few minutes.",
+ "branding-verify-bluesky-description": "Also count Bluesky's blue check: accounts Bluesky's public app view reports as verified can chat here too, and wear Bluesky's check rather than your badge.",
+ "branding-verify-bluesky-off": "Your verifiers only",
+ "branding-verify-bluesky-on": "Plus Bluesky's blue check",
"branding-quick-login-return-description": "Appended to the identity app's sign-in link on phones as ?return=, so the app knows where to send people back. Leave empty to append nothing.",
"branding-verify-url-description": "Where a signed-in but unverified user goes to get verified: the \"Verify now\" link under the composer when chat is verified-only. Unset shows no link.",
"branding-verified-only-message-description": "What the composer says when chat is verified-only and the viewer can't write, and the label of the verify link beside it. Signed-out viewers get a sign-in link instead.",
diff --git a/js/web/public/locales/en-US/settings.json b/js/web/public/locales/en-US/settings.json
index 1d692d32a..9723c9866 100644
--- a/js/web/public/locales/en-US/settings.json
+++ b/js/web/public/locales/en-US/settings.json
@@ -252,6 +252,9 @@
"branding-verifiers-description": "DIDs of the verifiers this node trusts, as a JSON list. Anyone they have issued an app.bsky.graph.verification record for shows a verified badge in chat. Bluesky's own verification shows its blue check regardless.",
"branding-labeler-description": "Or a labeler and the label values that mean verified on your network: the labeler's DID, then the labels, comma-separated, with a trailing * for a prefix (e.g. verified-*). Accounts carrying one of those labels count as verified here, next to any verifiers above.",
"branding-verify-appview-description": "Or an app view whose getProfile carries your network's own verification field: its URL, the field name (default wsocialVerified) and the values that count, comma-separated, or * for any value. The node asks it about each chat author on first sight and re-checks every few minutes.",
+ "branding-verify-bluesky-description": "Also count Bluesky's blue check: accounts Bluesky's public app view reports as verified can chat here too, and wear Bluesky's check rather than your badge.",
+ "branding-verify-bluesky-off": "Your verifiers only",
+ "branding-verify-bluesky-on": "Plus Bluesky's blue check",
"branding-quick-login-return-description": "Appended to the identity app's sign-in link on phones as ?return=, so the app knows where to send people back. Leave empty to append nothing.",
"branding-verify-url-description": "Where a signed-in but unverified user goes to get verified: the \"Verify now\" link under the composer when chat is verified-only. Unset shows no link.",
"branding-verified-only-message-description": "What the composer says when chat is verified-only and the viewer can't write, and the label of the verify link beside it. Signed-out viewers get a sign-in link instead.",
diff --git a/pkg/atproto/appview_verification.go b/pkg/atproto/appview_verification.go
index cc3444a92..7e858d7a4 100644
--- a/pkg/atproto/appview_verification.go
+++ b/pkg/atproto/appview_verification.go
@@ -17,8 +17,10 @@ import (
// App-view verification: a network whose notion of "verified" is a field on
// its own app view's getProfile (branding keys verifyAppViewUrl,
-// verifyAppViewField, verifyAppViewValues). There is no feed of changes to
-// subscribe to, so the node asks:
+// verifyAppViewField, verifyAppViewValues), and, with verifyBluesky on,
+// Bluesky's public app view, whose getProfile carries the blue check
+// (verification.verifiedStatus). There is no feed of changes to subscribe
+// to, so the node asks:
//
// - on first sight of an account (a chat message, a getStatus for it), a
// synchronous lookup with a short timeout, so a verified viewer's very
@@ -40,12 +42,36 @@ const (
type appViewConfig struct {
URL string
Host string
- Field string
+ issuer string // "" = did:web:Host
+ Fields []string // dotted paths into the profile; any one matching counts
Values []string // empty = any non-empty value
}
// Issuer is the verifier DID the app view's rows are filed under.
-func (c appViewConfig) Issuer() string { return "did:web:" + c.Host }
+func (c appViewConfig) Issuer() string {
+ if c.issuer != "" {
+ return c.issuer
+ }
+ return "did:web:" + c.Host
+}
+
+// Bluesky's blue check: the public app view says verifiedStatus (a
+// verified account) or trustedVerifierStatus (a verifier, whose check is
+// scalloped) is valid.
+const (
+ blueskyAppViewURL = "https://public.api.bsky.app"
+ BlueskyAppViewIssuer = "did:web:api.bsky.app"
+)
+
+func blueskyAppViewConfig() appViewConfig {
+ return appViewConfig{
+ URL: blueskyAppViewURL,
+ Host: "public.api.bsky.app",
+ issuer: BlueskyAppViewIssuer,
+ Fields: []string{"verification.verifiedStatus", "verification.trustedVerifierStatus"},
+ Values: []string{"valid"},
+ }
+}
func parseAppViewConfig(rawURL, field, values string) appViewConfig {
rawURL = strings.TrimSpace(rawURL)
@@ -56,10 +82,11 @@ func parseAppViewConfig(rawURL, field, values string) appViewConfig {
if err != nil || u.Host == "" {
return appViewConfig{}
}
- c := appViewConfig{URL: strings.TrimRight(rawURL, "/"), Host: u.Host, Field: strings.TrimSpace(field)}
- if c.Field == "" {
- c.Field = "wsocialVerified"
+ c := appViewConfig{URL: strings.TrimRight(rawURL, "/"), Host: u.Host}
+ if field = strings.TrimSpace(field); field == "" {
+ field = "wsocialVerified"
}
+ c.Fields = []string{field}
for _, v := range strings.Split(values, ",") {
if v = strings.TrimSpace(v); v != "" && v != "*" {
c.Values = append(c.Values, v)
@@ -85,6 +112,31 @@ func (c appViewConfig) matches(v any) bool {
return false
}
+// verified reports whether a getProfile view counts as verified: any of
+// the configured fields matches.
+func (c appViewConfig) verified(profile map[string]any) bool {
+ for _, f := range c.Fields {
+ if c.matches(fieldValue(profile, f)) {
+ return true
+ }
+ }
+ return false
+}
+
+// fieldValue walks a dotted path ("verification.verifiedStatus") into a
+// decoded JSON object; nil when any step is missing.
+func fieldValue(obj map[string]any, path string) any {
+ var cur any = obj
+ for _, key := range strings.Split(path, ".") {
+ m, ok := cur.(map[string]any)
+ if !ok {
+ return nil
+ }
+ cur = m[key]
+ }
+ return cur
+}
+
// appViewVerificationURI is the synthetic record URI of a mirrored answer.
func appViewVerificationURI(host, did string) string {
return fmt.Sprintf("appview://%s/%s", host, did)
@@ -92,26 +144,38 @@ func appViewVerificationURI(host, did string) string {
var (
appViewNegMu sync.Mutex
- appViewNeg = map[string]time.Time{} // did -> when the "no" expires
+ appViewNeg = map[string]time.Time{} // issuer+did -> when the "no" expires
appViewGroup singleflight.Group
)
-// appViewLookup asks the app view about did if nothing vouches for it yet
-// and no recent "no" is remembered. It returns true when the app view says
-// verified (and the row has been written). Callers on hot paths share one
-// in-flight request per DID.
+func appViewNegKey(c appViewConfig, did string) string { return c.Issuer() + " " + did }
+
+// appViewLookup asks each app view about did, in order, if nothing vouches
+// for it yet, stopping at the first yes. It returns true when one says
+// verified (and the row has been written).
func (atsync *ATProtoSynchronizer) appViewLookup(ctx context.Context, did string) bool {
- c := atsync.AppView(ctx)
- if c.URL == "" || did == "" {
+ if did == "" {
return false
}
+ for _, c := range atsync.AppViews(ctx) {
+ if atsync.appViewLookupOne(ctx, c, did) {
+ return true
+ }
+ }
+ return false
+}
+
+// appViewLookupOne asks one app view about did unless a recent "no" is
+// remembered. Callers on hot paths share one in-flight request per DID.
+func (atsync *ATProtoSynchronizer) appViewLookupOne(ctx context.Context, c appViewConfig, did string) bool {
+ key := appViewNegKey(c, did)
appViewNegMu.Lock()
- until, denied := appViewNeg[did]
+ until, denied := appViewNeg[key]
appViewNegMu.Unlock()
if denied && time.Now().Before(until) {
return false
}
- v, _, _ := appViewGroup.Do(did, func() (any, error) {
+ v, _, _ := appViewGroup.Do(key, func() (any, error) {
lctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), appViewLookupTimeout)
defer cancel()
res, err := atsync.checkAppView(lctx, c, []string{did})
@@ -131,9 +195,10 @@ func (atsync *ATProtoSynchronizer) appViewLookup(ctx context.Context, did string
// (and no row) for no.
func (atsync *ATProtoSynchronizer) applyAppViewAnswer(ctx context.Context, c appViewConfig, did string, verified bool) {
uri := appViewVerificationURI(c.Host, did)
+ key := appViewNegKey(c, did)
if verified {
appViewNegMu.Lock()
- delete(appViewNeg, did)
+ delete(appViewNeg, key)
appViewNegMu.Unlock()
err := atsync.Model.CreateVerification(ctx, &model.Verification{
URI: uri,
@@ -147,7 +212,7 @@ func (atsync *ATProtoSynchronizer) applyAppViewAnswer(ctx context.Context, c app
return
}
appViewNegMu.Lock()
- appViewNeg[did] = time.Now().Add(appViewNegativeTTL)
+ appViewNeg[key] = time.Now().Add(appViewNegativeTTL)
appViewNegMu.Unlock()
if err := atsync.Model.DeleteVerification(ctx, uri); err != nil {
log.Warn(ctx, "failed to clear app view verification", "did", did, "err", err)
@@ -194,13 +259,13 @@ func (atsync *ATProtoSynchronizer) checkAppView(ctx context.Context, c appViewCo
if did == "" {
continue
}
- out[did] = c.matches(p[c.Field])
+ out[did] = c.verified(p)
}
}
return out, nil
}
-// RefreshAppViewVerificationsForever re-asks the app view about every
+// RefreshAppViewVerificationsForever re-asks each app view about every
// account it has vouched for, in batches, so a verification the network
// withdraws stops counting here within appViewRefreshInterval. Accounts
// that were never verified are re-asked on sight instead (see
@@ -213,36 +278,38 @@ func (atsync *ATProtoSynchronizer) RefreshAppViewVerificationsForever(ctx contex
return
case <-time.After(appViewRefreshInterval):
}
- c := atsync.AppView(ctx)
- if c.URL == "" {
- continue
- }
- rows, err := atsync.Model.ListVerificationsByIssuer(ctx, c.Issuer())
- if err != nil {
- log.Warn(ctx, "failed to list app view verifications", "err", err)
- continue
- }
- dids := make([]string, 0, len(rows))
- for _, r := range rows {
- dids = append(dids, r.SubjectDID)
+ for _, c := range atsync.AppViews(ctx) {
+ atsync.refreshAppView(ctx, c)
}
- if len(dids) == 0 {
- continue
- }
- res, err := atsync.checkAppView(ctx, c, dids)
- if err != nil {
- log.Warn(ctx, "app view verification refresh failed", "err", err)
- continue
- }
- revoked := 0
- for _, did := range dids {
- if !res[did] {
- atsync.applyAppViewAnswer(ctx, c, did, false)
- revoked++
- }
- }
- if revoked > 0 {
- log.Log(ctx, "app view verifications refreshed", "checked", len(dids), "revoked", revoked)
+ }
+}
+
+func (atsync *ATProtoSynchronizer) refreshAppView(ctx context.Context, c appViewConfig) {
+ rows, err := atsync.Model.ListVerificationsByIssuer(ctx, c.Issuer())
+ if err != nil {
+ log.Warn(ctx, "failed to list app view verifications", "issuer", c.Issuer(), "err", err)
+ return
+ }
+ dids := make([]string, 0, len(rows))
+ for _, r := range rows {
+ dids = append(dids, r.SubjectDID)
+ }
+ if len(dids) == 0 {
+ return
+ }
+ res, err := atsync.checkAppView(ctx, c, dids)
+ if err != nil {
+ log.Warn(ctx, "app view verification refresh failed", "issuer", c.Issuer(), "err", err)
+ return
+ }
+ revoked := 0
+ for _, did := range dids {
+ if !res[did] {
+ atsync.applyAppViewAnswer(ctx, c, did, false)
+ revoked++
}
}
+ if revoked > 0 {
+ log.Log(ctx, "app view verifications refreshed", "issuer", c.Issuer(), "checked", len(dids), "revoked", revoked)
+ }
}
diff --git a/pkg/atproto/appview_verification_test.go b/pkg/atproto/appview_verification_test.go
index 9b4cfe854..a7c5d4698 100644
--- a/pkg/atproto/appview_verification_test.go
+++ b/pkg/atproto/appview_verification_test.go
@@ -14,7 +14,7 @@ func TestParseAppViewConfig(t *testing.T) {
c := parseAppViewConfig("https://api.example.com/", "", "")
require.Equal(t, "https://api.example.com", c.URL)
require.Equal(t, "did:web:api.example.com", c.Issuer())
- require.Equal(t, "wsocialVerified", c.Field)
+ require.Equal(t, []string{"wsocialVerified"}, c.Fields)
require.True(t, c.matches("wid"), "* means any non-empty value")
require.False(t, c.matches(""))
require.False(t, c.matches(nil))
@@ -27,6 +27,20 @@ func TestParseAppViewConfig(t *testing.T) {
require.Equal(t, appViewConfig{}, parseAppViewConfig("not a url", "", ""))
}
+func TestBlueskyAppViewConfig(t *testing.T) {
+ c := blueskyAppViewConfig()
+ require.Equal(t, BlueskyAppViewIssuer, c.Issuer())
+ verified := map[string]any{"verification": map[string]any{"verifiedStatus": "valid", "trustedVerifierStatus": "none"}}
+ verifier := map[string]any{"verification": map[string]any{"verifiedStatus": "none", "trustedVerifierStatus": "valid"}}
+ invalid := map[string]any{"verification": map[string]any{"verifiedStatus": "invalid", "trustedVerifierStatus": "none"}}
+ plain := map[string]any{"did": "did:plc:x"}
+ require.True(t, c.verified(verified))
+ require.True(t, c.verified(verifier), "a trusted verifier wears a check too")
+ require.False(t, c.verified(invalid))
+ require.False(t, c.verified(plain))
+ require.Nil(t, fieldValue(map[string]any{"verification": "oops"}, "verification.verifiedStatus"))
+}
+
func TestCheckAppView(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/xrpc/app.bsky.actor.getProfiles", r.URL.Path)
diff --git a/pkg/atproto/verification.go b/pkg/atproto/verification.go
index ca9e8fca5..455f8a517 100644
--- a/pkg/atproto/verification.go
+++ b/pkg/atproto/verification.go
@@ -32,7 +32,7 @@ var (
verifiedOnlyC bool
labelerCached string
labelPatternsC []string
- appViewCached appViewConfig
+ appViewsCached []appViewConfig
)
// Verifiers returns the trusted verifier DIDs, cached briefly.
@@ -73,26 +73,34 @@ func (atsync *ATProtoSynchronizer) Verifiers(ctx context.Context) []string {
labelerCached = ""
}
// An app view with its own verification field is a verifier too,
- // under the did:web of its host (see appview_verification.go).
- appViewCached = parseAppViewConfig(
+ // under the did:web of its host (see appview_verification.go); so
+ // is Bluesky's public app view, for the blue check, when verifyBluesky
+ // is on. The network's own view is asked first.
+ appViewsCached = nil
+ if c := parseAppViewConfig(
branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyAppViewUrl"),
branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyAppViewField"),
branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyAppViewValues"),
- )
- if appViewCached.URL != "" {
- verifierCached = append(verifierCached, appViewCached.Issuer())
+ ); c.URL != "" {
+ appViewsCached = append(appViewsCached, c)
+ }
+ if branding.Text(atsync.StatefulDB, atsync.CLI.BroadcasterHost, "verifyBluesky") == "on" {
+ appViewsCached = append(appViewsCached, blueskyAppViewConfig())
+ }
+ for _, c := range appViewsCached {
+ verifierCached = append(verifierCached, c.Issuer())
}
}
verifierAt = time.Now()
return verifierCached
}
-// AppView returns the verifying app view's configuration; URL "" when unset.
-func (atsync *ATProtoSynchronizer) AppView(ctx context.Context) appViewConfig {
+// AppViews returns the verifying app views, in the order they are asked.
+func (atsync *ATProtoSynchronizer) AppViews(ctx context.Context) []appViewConfig {
atsync.Verifiers(ctx)
verifierMu.Lock()
defer verifierMu.Unlock()
- return appViewCached
+ return appViewsCached
}
// Labeler returns the verifying labeler's DID and the label values (exact,
diff --git a/pkg/branding/vocab.go b/pkg/branding/vocab.go
index 9a94301b6..a6d0a6b71 100644
--- a/pkg/branding/vocab.go
+++ b/pkg/branding/vocab.go
@@ -88,6 +88,7 @@ var Specs = []Spec{
{Key: "verifiedLabels", Kind: KindText, MaxSize: textMax, Doc: "Label values from labelerDid that count as verified, comma-separated; a trailing * matches a prefix: \"wsocial-*\" or \"verified,org\"."},
{Key: "verifyAppViewUrl", Kind: KindText, MaxSize: textMax, Doc: "An app view whose app.bsky.actor.getProfile carries the network's own verification field (see verifyAppViewField): https://api.example.com. The node asks it about each chat author on first sight and re-checks on a schedule; verified accounts count as verified here."},
{Key: "verifyAppViewField", Kind: KindText, MaxSize: textMax, Doc: "The getProfile field that marks a verified account on verifyAppViewUrl. Default \"wsocialVerified\"."},
+ {Key: "verifyBluesky", Kind: KindEnum, Enum: []string{"on", "off"}, MaxSize: textMax, Doc: "Bluesky accounts with a blue check (verified on Bluesky's public app view, api.bsky.app) count as verified here too, alongside the node's own verifiers; they wear Bluesky's check rather than the verifiedIcon."},
{Key: "verifyAppViewValues", Kind: KindText, MaxSize: textMax, Doc: "Field values that count as verified, comma-separated; \"*\" (the default) means any non-empty value."},
{Key: "quickLoginReturn", Kind: KindText, MaxSize: textMax, Doc: "Value of the return parameter appended to the identity app's sign-in deep link on phones (…?return=), so the app knows where to send the viewer back; unset appends nothing."},
{Key: "verifyUrl", Kind: KindText, MaxSize: textMax, Doc: "Where a signed-in but unverified user goes to get verified (the composer's \"Verify now\" link); unset shows no link."},