diff --git a/docs/content/docs/api/error-codes.mdx b/docs/content/docs/api/error-codes.mdx index 5ad58296f..c41ea56a9 100644 --- a/docs/content/docs/api/error-codes.mdx +++ b/docs/content/docs/api/error-codes.mdx @@ -163,6 +163,7 @@ Names are shown to other people, including in invitation and notification emails - no links, web addresses or email addresses: nothing with `@`, `//`, `www.` or a scheme such as `https:`, no hostname such as `example.com`, and no IP address. Full-width and ideographic dots count as dots - no control characters, invisible formatting characters (zero-width and bidirectional overrides), `<`, `>`, `` ` `` or `\`, and no more than three stacked combining marks - leading and trailing spaces are trimmed and runs of spaces become one, and that is the form stored +- an OAuth app name cannot include Warmbly, spaced out or spelled with look-alike characters included. An app that already had such a name keeps it until the name is changed ```json { diff --git a/docs/content/docs/api/mcp.mdx b/docs/content/docs/api/mcp.mdx index 1ef9f4484..cf0e55cec 100644 --- a/docs/content/docs/api/mcp.mdx +++ b/docs/content/docs/api/mcp.mdx @@ -36,7 +36,7 @@ Add the server with just its URL, and the client runs the OAuth flow on first us ``` -The consent screen shows exactly which scopes the client will get: the ones it asks for that your role in the workspace covers, so a connected client never does more than you could yourself. Sending real mail is never one of them: `SEND_CAMPAIGNS` and API-key management are excluded from OAuth-connected MCP clients by design, and the assistant only ever drafts. You can revoke a connected client at any time from your dashboard under connected apps. +The consent screen marks a self-registered client as **Not affiliated with Warmbly**, names where approving sends access, and shows exactly which scopes the client will get: the ones it asks for that your role in the workspace covers, so a connected client never does more than you could yourself. Sending real mail is never one of them: `SEND_CAMPAIGNS` and API-key management are excluded from OAuth-connected MCP clients by design, and the assistant only ever drafts. You can revoke a connected client at any time from your dashboard under connected apps. ## Connect with an API key diff --git a/docs/content/docs/api/oauth.mdx b/docs/content/docs/api/oauth.mdx index 1c1a1617e..448f08048 100644 --- a/docs/content/docs/api/oauth.mdx +++ b/docs/content/docs/api/oauth.mdx @@ -11,7 +11,7 @@ Warmbly implements the OAuth 2.1 authorization-code flow with PKCE. Confidential In the dashboard, open **Settings -> OAuth apps** and register an application. You provide: -- a **name**, optional description, logo, and website (shown on the consent screen), +- a **name**, optional description, logo, and website (shown on the consent screen). The name follows the [naming rules](/api/error-codes/#name-refusals) and cannot include Warmbly, spaced out or spelled with look-alike characters included, because people would take the app for Warmbly's own. An app that already had such a name keeps it until the name is changed, - one or more **redirect URIs** (where users are sent back after they approve), matched exactly and required to be HTTPS, except loopback URLs for native apps, - the **scopes** your app may request (any permission except `API_KEYS`, which stays with people and API keys), - optionally, **allowed webhook domains** (see below). @@ -67,6 +67,8 @@ curl -X POST "https://api.warmbly.com/v1/oauth/register" \ The registration endpoint is open (unauthenticated) and rate-limited per source IP. Registering grants no access on its own: a human still signs in and approves scopes at the consent screen. Redirect URIs may be HTTPS, loopback (`http://127.0.0.1` or `localhost`, any port), or a private-use scheme (`myapp://`), matched exactly at authorize and token time. Self-registered clients can never request `SEND_CAMPAIGNS` or `API_KEYS`. +Nobody vouches for a self-registered client, so the consent screen shows only what Warmbly can stand behind. `client_uri` and `logo_uri` are ignored, so the screen shows no website or logo for it. Its `client_name` is cleaned rather than refused: a bracketed part that names Warmbly is dropped (`Claude Code (warmbly)` is shown as `Claude Code`), and a name that still names Warmbly, or breaks the naming rules, is shown as `MCP client`. + For a public client the token exchange sends no `client_secret`, only the PKCE `code_verifier`. Everything else in the flow below is identical. If you are connecting an AI assistant, you do not run any of this by hand: see the [MCP server](/api/mcp/) page for the one-command setup. ## Scopes @@ -106,7 +108,7 @@ https://app.warmbly.com/oauth/authorize ### 2. The user approves -Warmbly shows the app, the workspace that will receive access, the scopes it will get, and an authorize or deny choice. An app is marked **Unverified app** unless it is a registered (not self-registered) app the instance features in its [community directory](#publish-to-the-community-directory). Approving asks the member to confirm it is them when they have not signed in or re-authenticated in the last few minutes, because the approval hands your app a standing credential. On approval the browser is redirected back to your `redirect_uri` with a single-use code: +Warmbly shows the app, the workspace that will receive access, the scopes it will get, where approving sends access (the redirect URI's host, or an app on this device for a loopback or private-use redirect), and an authorize or deny choice. An app is marked **Unverified app** unless it is a registered (not self-registered) app the instance features in its [community directory](#publish-to-the-community-directory), and an unverified app also carries a **Not affiliated with Warmbly** notice that asks the member to approve only a connection they started themselves. Approving asks the member to confirm it is them when they have not signed in or re-authenticated in the last few minutes, because the approval hands your app a standing credential. On approval the browser is redirected back to your `redirect_uri` with a single-use code: ``` https://yourapp.com/oauth/callback?code=wmac_...&state= diff --git a/internal/app/oauth/dcr.go b/internal/app/oauth/dcr.go index 5a94d6057..c273d7386 100644 --- a/internal/app/oauth/dcr.go +++ b/internal/app/oauth/dcr.go @@ -8,7 +8,6 @@ import ( "time" "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/pkg/displayname" ) // Dynamic Client Registration (RFC 7591). An MCP client (Claude Code, Cursor, @@ -23,7 +22,8 @@ import ( const dcrMaxPerIPPerHour = 20 // DCRRequest is the RFC 7591 client-registration request (only the members we -// honor; unknown members are ignored per the spec). +// honor; unknown members are ignored per the spec). client_uri and logo_uri are +// not honored: nobody vouches for a self-registered client's website or logo. type DCRRequest struct { ClientName string `json:"client_name"` RedirectURIs []string `json:"redirect_uris"` @@ -31,8 +31,6 @@ type DCRRequest struct { ResponseTypes []string `json:"response_types"` TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` Scope string `json:"scope"` - ClientURI string `json:"client_uri"` - LogoURI string `json:"logo_uri"` } // DCRResponse is the RFC 7591 client-information response. client_secret is set @@ -65,10 +63,7 @@ func (s *Service) RegisterDynamicClient(ctx context.Context, clientIP string, re } // A self-registered name nobody can be asked to correct is cleaned, not refused. - name := displayname.Clean(req.ClientName, displayname.Workspace) - if name == "" { - name = "MCP client" - } + name := dcrClientName(req.ClientName) uris, err := validateDCRRedirectURIs(req.RedirectURIs) if err != nil { @@ -104,7 +99,6 @@ func (s *Service) RegisterDynamicClient(ctx context.Context, clientIP string, re app := &models.OAuthApplication{ Name: name, - WebsiteURL: dcrWebsite(req.ClientURI), ClientID: clientID, RedirectURIs: uris, AllowedWebhookDomains: []string{}, @@ -222,14 +216,3 @@ func (s *Service) checkDCRRate(ctx context.Context, ip string) error { } return nil } - -// dcrWebsite keeps a self-registered client's website only when it is a plain -// http(s) address. Its logo_uri is never kept: the consent screen shows only -// images this instance stored. -func dcrWebsite(raw string) string { - w, err := appWebsite(raw) - if err != nil { - return "" - } - return w -} diff --git a/internal/app/oauth/flow.go b/internal/app/oauth/flow.go index 26c8ad050..320460ba7 100644 --- a/internal/app/oauth/flow.go +++ b/internal/app/oauth/flow.go @@ -42,6 +42,8 @@ type ConsentInfo struct { OrganizationName string `json:"organization_name"` // Verified is a registered app the instance's operators feature in the directory. Verified bool `json:"verified"` + // SelfRegistered is a client that registered itself (RFC 7591), so nobody vouches for its name. + SelfRegistered bool `json:"self_registered"` } // TokenResponse is the /token success body (RFC 6749 §5.1). @@ -86,15 +88,18 @@ func (s *Service) AuthorizeDetails(ctx context.Context, roleCap uint64, req Auth if werr != nil { website = "" } + name := app.Name verified := false - if !app.DynamicallyRegistered { - if featured, ferr := s.repo.IsFeatured(ctx, app.ID); ferr == nil { - verified = featured - } + if app.DynamicallyRegistered { + // Rows stored before these rules are held to them on the way out too. + name = dcrClientName(app.Name) + website = "" + } else if featured, ferr := s.repo.IsFeatured(ctx, app.ID); ferr == nil { + verified = featured } return &ConsentInfo{ ClientID: app.ClientID, - Name: app.Name, + Name: name, Description: app.Description, LogoURL: app.LogoURL, WebsiteURL: website, @@ -103,6 +108,7 @@ func (s *Service) AuthorizeDetails(ctx context.Context, roleCap uint64, req Auth WithheldScopes: ScopeList(withheld), State: req.State, Verified: verified, + SelfRegistered: app.DynamicallyRegistered, }, nil } diff --git a/internal/app/oauth/imported.go b/internal/app/oauth/imported.go index b71dcb82e..f61057481 100644 --- a/internal/app/oauth/imported.go +++ b/internal/app/oauth/imported.go @@ -41,7 +41,11 @@ func IssuedLogoKey(pu storage.PublicURLer, orgID uuid.UUID, logoURL string) (str // ImportedName is the archive's app name under the naming rules, or a neutral one. func ImportedName(raw string) string { - return displayname.CleanOr(raw, displayname.Workspace, "Imported app") + name := displayname.CleanOr(raw, displayname.Workspace, "Imported app") + if mentionsPlatform(name) { + return "Imported app" + } + return name } // ImportedWebsite is the archive's website when it is an http(s) address. diff --git a/internal/app/oauth/naming.go b/internal/app/oauth/naming.go new file mode 100644 index 000000000..cbefceb76 --- /dev/null +++ b/internal/app/oauth/naming.go @@ -0,0 +1,79 @@ +package oauth + +import ( + "regexp" + "strings" + + "golang.org/x/text/unicode/norm" + + "github.com/warmbly/warmbly/internal/errx" + "github.com/warmbly/warmbly/internal/pkg/displayname" +) + +// platformName is the name no third-party app may present itself under. +const platformName = "warmbly" + +// dcrFallbackName stands in for a self-registered client with no usable name. +const dcrFallbackName = "MCP client" + +var ( + // confusables folds look-alike characters onto plain letters, after NFKC and lowercasing. + confusables = strings.NewReplacer( + "0", "o", "1", "l", "|", "l", "!", "l", "i", "l", "ı", "l", "4", "a", "@", "a", + "а", "a", "α", "a", "ɑ", "a", "у", "y", "ʏ", "y", "ԝ", "w", "ᴡ", "w", + "ʀ", "r", "ᴍ", "m", "ʙ", "b", "ʟ", "l", + ) + digraphs = strings.NewReplacer("rn", "m", "vv", "w") + // A bracketed aside, as MCP clients append the server's local label: "Claude Code (warmbly)". + asideRe = regexp.MustCompile(`\s*[(\[][^()\[\]]*[)\]]`) +) + +// mentionsPlatform reports whether name spells the platform's name, through +// spacing, punctuation and look-alike characters. +func mentionsPlatform(name string) bool { + folded := confusables.Replace(strings.ToLower(norm.NFKC.String(name))) + var b strings.Builder + for _, r := range folded { + if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return strings.Contains(digraphs.Replace(b.String()), platformName) +} + +func errPlatformName() error { + return errx.NewWithIdentifier(errx.BadRequest, displayname.ErrorCode, + "name cannot include Warmbly: people connecting the app would take it for Warmbly's own.") +} + +// appName applies the shared naming rules plus the platform-name rule. A name +// left as it was (current) keeps passing, so an existing app stays editable. +// An app name is shown to every workspace that sees its consent screen or its directory listing. +func appName(raw, current string) (string, error) { + name, xerr := displayname.Validate("name", raw, displayname.Workspace, false) + if xerr != nil { + return "", xerr + } + if name != current && mentionsPlatform(name) { + return "", errPlatformName() + } + return name, nil +} + +// dcrClientName is a self-registered client's name with any aside naming the +// platform dropped, or a neutral name when what remains still names it. +func dcrClientName(raw string) string { + name := displayname.Clean(raw, displayname.Workspace) + if mentionsPlatform(name) { + name = displayname.Clean(asideRe.ReplaceAllStringFunc(name, func(aside string) string { + if mentionsPlatform(aside) { + return "" + } + return aside + }), displayname.Workspace) + } + if name == "" || mentionsPlatform(name) { + return dcrFallbackName + } + return name +} diff --git a/internal/app/oauth/naming_test.go b/internal/app/oauth/naming_test.go index ecf32d75a..ad8541607 100644 --- a/internal/app/oauth/naming_test.go +++ b/internal/app/oauth/naming_test.go @@ -3,11 +3,11 @@ package oauth import "testing" func TestAppNameFollowsTheNamingRules(t *testing.T) { - if got, err := appName(" Acme Sync "); err != nil || got != "Acme Sync" { + if got, err := appName(" Acme Sync ", ""); err != nil || got != "Acme Sync" { t.Fatalf("appName = %q, %v", got, err) } for _, bad := range []string{"", "Verify at evil.example", "https://acme.com", "Acme ‮ppa", "Acme"} { - if _, err := appName(bad); err == nil { + if _, err := appName(bad, ""); err == nil { t.Errorf("appName(%q) accepted", bad) } } @@ -24,7 +24,44 @@ func TestAppWebsite(t *testing.T) { t.Errorf("appWebsite(%q) accepted", bad) } } - if dcrWebsite("javascript:alert(1)") != "" { - t.Fatal("a self-registered client kept an unsafe website") +} + +func TestAppNameRefusesThePlatformName(t *testing.T) { + for _, bad := range []string{"Warmbly", "Warmbly Support", "HubSpot for warmbly", "W a r m b l y", "Warm-bly", "WarmbIy", "Warrnbly", "VVarmbly", "Wаrmbly", "Warmbly"} { + if _, err := appName(bad, ""); err == nil { + t.Errorf("appName(%q) accepted", bad) + } + } + for _, ok := range []string{"Acme Sync", "Warm Leads", "Swarm"} { + if _, err := appName(ok, ""); err != nil { + t.Errorf("appName(%q) refused: %v", ok, err) + } + } + if _, err := appName("Warmbly Sync", "Warmbly Sync"); err != nil { + t.Errorf("an unchanged existing name was refused: %v", err) + } + if _, err := appName("Warmbly Sync 2", "Warmbly Sync"); err == nil { + t.Error("a changed name naming the platform was accepted") + } +} + +func TestDCRClientNameDropsThePlatformName(t *testing.T) { + cases := map[string]string{ + "Claude Code (warmbly)": "Claude Code", + "Cursor [Warmbly prod]": "Cursor", + "Cursor": "Cursor", + "Claude Code (work)": "Claude Code (work)", + "Warmbly Support": dcrFallbackName, + "Warmbly (Claude Code)": dcrFallbackName, + "": dcrFallbackName, + "https://warmbly.example": dcrFallbackName, + } + for in, want := range cases { + if got := dcrClientName(in); got != want { + t.Errorf("dcrClientName(%q) = %q, want %q", in, got, want) + } + } + if got := ImportedName("Warmbly Sync"); got != "Imported app" { + t.Errorf("ImportedName kept the platform name: %q", got) } } diff --git a/internal/app/oauth/service.go b/internal/app/oauth/service.go index d2ed5b556..a7ac7affb 100644 --- a/internal/app/oauth/service.go +++ b/internal/app/oauth/service.go @@ -23,7 +23,6 @@ import ( "github.com/warmbly/warmbly/internal/errx" "github.com/warmbly/warmbly/internal/infrastructure/cache" "github.com/warmbly/warmbly/internal/models" - "github.com/warmbly/warmbly/internal/pkg/displayname" "github.com/warmbly/warmbly/internal/pkg/whdomain" "github.com/warmbly/warmbly/internal/repository" ) @@ -68,16 +67,6 @@ func invalidf(format string, args ...any) error { return &ValidationError{msg: fmt.Sprintf(format, args...)} } -// appName applies the shared naming rules: an app name is shown to every -// workspace that sees its consent screen or its directory listing. -func appName(raw string) (string, error) { - name, xerr := displayname.Validate("name", raw, displayname.Workspace, false) - if xerr != nil { - return "", xerr - } - return name, nil -} - // appWebsite accepts an empty value or an http(s) address on a host, without // credentials; it is shown to other workspaces as a link. func appWebsite(raw string) (string, error) { @@ -151,7 +140,7 @@ func (s *Service) RegisterApplication(ctx context.Context, orgID, userID uuid.UU if err := s.CheckDeveloperAccess(ctx, orgID, userID); err != nil { return nil, err } - name, err := appName(w.Name) + name, err := appName(w.Name, "") if err != nil { return nil, err } @@ -226,7 +215,7 @@ func (s *Service) UpdateApplication(ctx context.Context, orgID, userID, id uuid. if app.SuspendedAt != nil { return nil, ErrAppSuspended } - name, err := appName(w.Name) + name, err := appName(w.Name, app.Name) if err != nil { return nil, err } diff --git a/web/src/app/app/settings/oauth-apps/RegisterAppDialog.tsx b/web/src/app/app/settings/oauth-apps/RegisterAppDialog.tsx index 1e9127047..4fd5a232a 100644 --- a/web/src/app/app/settings/oauth-apps/RegisterAppDialog.tsx +++ b/web/src/app/app/settings/oauth-apps/RegisterAppDialog.tsx @@ -15,7 +15,7 @@ import { CheckSquare } from "@/components/ui/check-square"; import { Label, TextInput } from "@/components/ui/field"; import { useConfirm } from "@/hooks/context/confirm"; import useAPIPermissions from "@/lib/api/hooks/app/api-keys/useAPIPermissions"; -import { nameError } from "@/lib/displayName"; +import { appNameError } from "@/lib/displayName"; import { useCreateOAuthApp, useSetOAuthAppLogo } from "@/lib/api/hooks/app/oauth/useOAuthApps"; import { useWebhookEventCatalog } from "@/lib/api/hooks/app/webhooks/useWebhooks"; import type APIPermission from "@/lib/api/models/app/apikeys/APIPermission"; @@ -90,7 +90,7 @@ export default function RegisterAppDialog({ onClose }: { onClose: () => void }) const redirectList = redirects.map((r) => r.trim()).filter(Boolean); const redirectErrors = redirects.map(redirectProblem); - const nameProblem = nameError("Name", name, "workspace"); + const nameProblem = appNameError("Name", name); const websiteProblem = website.trim() && !/^https?:\/\/[^\s/]+/i.test(website.trim()) ? "Start with https://" : null; const webhookProblem = webhooksOn && webhookUrl.trim() && !/^https:\/\/[^\s/]+/i.test(webhookUrl.trim()) ? "Use an https address" : null; diff --git a/web/src/app/app/settings/oauth-apps/page.tsx b/web/src/app/app/settings/oauth-apps/page.tsx index a6ebb2313..6bc1eb4d2 100644 --- a/web/src/app/app/settings/oauth-apps/page.tsx +++ b/web/src/app/app/settings/oauth-apps/page.tsx @@ -27,7 +27,7 @@ import { usePermission } from "@/hooks/usePermission"; import { EmptyBlock } from "@/components/layout/Page"; import { Label, TextInput } from "@/components/ui/field"; import { cn } from "@/lib/utils"; -import { nameError } from "@/lib/displayName"; +import { appNameError } from "@/lib/displayName"; import { useConfirm } from "@/hooks/context/confirm"; import useAPIPermissions from "@/lib/api/hooks/app/api-keys/useAPIPermissions"; import type APIPermission from "@/lib/api/models/app/apikeys/APIPermission"; @@ -607,7 +607,7 @@ function EditModal({ app, onClose }: { app: OAuthApplication; onClose: () => voi const webhookUrlValid = webhookUrl.trim() === "" || /^https:\/\/.+/i.test(webhookUrl.trim()); const save = async () => { - const nameProblem = nameError("Name", name, "workspace"); + const nameProblem = appNameError("Name", name, app.name); if (nameProblem) { toast.error(nameProblem); return; diff --git a/web/src/app/oauth/authorize/page.tsx b/web/src/app/oauth/authorize/page.tsx index e27919770..60cfa7a52 100644 --- a/web/src/app/oauth/authorize/page.tsx +++ b/web/src/app/oauth/authorize/page.tsx @@ -40,6 +40,21 @@ function isSafeRedirect(rawUrl: string): boolean { } } +// Where approving sends the code, phrased for a person: a loopback address is a +// program on this device, a private-use scheme names the app that claims it. +function redirectDestination(rawUrl: string): string { + try { + const u = new URL(rawUrl); + if (u.protocol === "http:" || u.protocol === "https:") { + const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]"; + return loopback ? "an app on this device" : u.host; + } + return `the ${u.protocol.replace(/:$/, "")} app on this device`; + } catch { + return "the app"; + } +} + export default function OAuthConsentPage() { const [sp] = useSearchParams(); const params = React.useMemo(() => Object.fromEntries(sp.entries()), [sp]); @@ -142,7 +157,6 @@ export default function OAuthConsentPage() {

{info.name}

{!info.verified && ( @@ -166,6 +180,21 @@ export default function OAuthConsentPage() { )} + {!info.verified && ( +
+ +
+

Not affiliated with Warmbly

+

+ {info.self_registered + ? "This app registered itself and chose its own name, so Warmbly cannot say who made it." + : "Someone outside Warmbly made this app, and Warmbly has not reviewed it."}{" "} + Approve only if you started this connection yourself. +

+
+
+ )} +
It will be able to
    @@ -211,13 +240,8 @@ export default function OAuthConsentPage() {

- You'll be redirected to {(() => { - try { - return new URL(info.redirect_uri).host; - } catch { - return "the app"; - } - })()}. Only authorize apps you trust. + Authorizing sends access to {redirectDestination(info.redirect_uri)}. Only + authorize apps you trust.

); diff --git a/web/src/lib/api/models/app/oauth/OAuthApp.ts b/web/src/lib/api/models/app/oauth/OAuthApp.ts index 761766609..71c7b876c 100644 --- a/web/src/lib/api/models/app/oauth/OAuthApp.ts +++ b/web/src/lib/api/models/app/oauth/OAuthApp.ts @@ -71,6 +71,8 @@ export interface OAuthConsentInfo { organization_name: string; // A registered app the instance features in its directory. verified: boolean; + // Registered itself (RFC 7591), so nobody vouches for its name. + self_registered: boolean; } // An app the current user has authorized (GET /oauth/authorized-apps). diff --git a/web/src/lib/displayName.test.ts b/web/src/lib/displayName.test.ts index 316ee759a..21b7c24cf 100644 --- a/web/src/lib/displayName.test.ts +++ b/web/src/lib/displayName.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { nameError } from "./displayName"; +import { appNameError, nameError } from "./displayName"; describe("nameError", () => { it.each(["Ada", " Mary Ann ", "O'Brien-Smith", "J.R.R. Tolkien", "St. John", "Zoë", "李小龙"])( @@ -39,3 +39,17 @@ describe("nameError", () => { expect(nameError("Name", "3000", "person")).toMatch(/letter/); }); }); + +describe("appNameError", () => { + it.each(["Warmbly", "Warmbly Support", "HubSpot for warmbly", "W a r m b l y", "WarmbIy", "Warrnbly", "VVarmbly", "Wаrmbly", "Warmbly"])( + "refuses %s", + (v) => expect(appNameError("Name", v)).toMatch(/cannot include Warmbly/), + ); + + it.each(["Acme Sync", "Warm Leads", "Swarm"])("accepts %s", (v) => expect(appNameError("Name", v)).toBeNull()); + + it("keeps an existing name editable", () => { + expect(appNameError("Name", "Warmbly Sync", "Warmbly Sync")).toBeNull(); + expect(appNameError("Name", "Warmbly Sync 2", "Warmbly Sync")).toMatch(/cannot include Warmbly/); + }); +}); diff --git a/web/src/lib/displayName.ts b/web/src/lib/displayName.ts index 741c3c853..af40985fa 100644 --- a/web/src/lib/displayName.ts +++ b/web/src/lib/displayName.ts @@ -45,3 +45,30 @@ export function nameError(label: string, value: string, kind: NameKind, optional } return null; } + +// Mirrors mentionsPlatform in internal/app/oauth/naming.go. +const CONFUSABLES: Record = { + "0": "o", "1": "l", "|": "l", "!": "l", i: "l", "ı": "l", "4": "a", "@": "a", + "а": "a", "α": "a", "ɑ": "a", "у": "y", "ʏ": "y", "ԝ": "w", "ᴡ": "w", + "ʀ": "r", "ᴍ": "m", "ʙ": "b", "ʟ": "l", +}; + +function mentionsPlatform(value: string): boolean { + const folded = [...value.normalize("NFKC").toLowerCase()] + .map((ch) => CONFUSABLES[ch] ?? ch) + .join("") + .replace(/[^a-z]/g, "") + .replace(/rn|vv/g, (d) => (d === "rn" ? "m" : "w")); + return folded.includes("warmbly"); +} + +/** nameError for an OAuth app, which also may not name Warmbly unless it already did (current). */ +export function appNameError(label: string, value: string, current = ""): string | null { + const problem = nameError(label, value, "workspace"); + if (problem) return problem; + const name = normalizeName(value); + if (name !== current && mentionsPlatform(name)) { + return `${label} cannot include Warmbly: people connecting the app would take it for Warmbly's own.`; + } + return null; +}