A public, no-login pre-screen for a proposed brand, product or project name: what already exists that could conflict with it. One report from several upstreams of uneven reliability — trade mark registries, company registers, RDAP, web search — with per-source coverage, dependency-free name similarity, and a descriptive conflict signal that never claims legal availability.
Page: /showcases/name-check · API: POST /api/name-check · Domain:
src/lib/server/name-check/ · Contract: src/lib/name-check/report.ts ·
Schema: src/lib/schemas/name-check.ts
The problem it solves
Clearing a name means searching six databases by hand and reconciling what they say. Each one has a different API story: EUIPO has an official one behind a registered app, GLEIF is keyless, DPMA sells a contract, WIPO and USPTO publish none, domain registries speak RDAP (some of them), and "is anyone already trading under this name" is a web search. The feature treats that as a search aggregation problem: one query, every source at once, partial results as a first-class outcome.
What it deliberately is not: a trademark clearance. The report says what was found and
where the search could not look. It never says "available", "safe", or a risk percentage —
report.ts has no field that could render as one.
Vocabulary
| Term | Meaning |
|---|---|
| name check | The feature. Domain name-check/, route /showcases/name-check, API /api/name-check, keys showcase_name_check_*. |
NameSource |
One upstream: id, kind, territories, manualUrl(), optional configured() and search(). No search ⇒ manual-only. Not "adapter" (*.adapter.ts is the transport seam) and not bare "source" (turn provenance). |
NameMatch |
One finding, discriminated on kind: trademark · company · domain · web. Each kind has its own facts; every one carries similarity, jurisdiction, territoryRelevance, url, retrievedAt. |
NameSimilarity |
{ score 0–100, basis }. Shown as "name similarity", never as a probability of anything. |
NameCheckCoverage |
Per source: complete · unavailable · quota_exhausted · credentials_missing · timed_out · manual_only, plus the manual URL, retrievedAt, cached. |
NameConflictSignal |
level (none · similar · potential · strong) + reasons (codes with params, localised on the page) + manualReviewRecommended. |
territory vs jurisdiction |
The query's scope (de · eu · worldwide) vs a record's origin (DE, EM for an EU mark, WO for an international registration). |
Pipeline
+page.server.ts actions.check ─┐
├─► checkName(query, deps)
POST /api/name-check ──────────┘ │ per source: territory filter → configured? →
│ readThrough(cache) → breaker → daily quota →
│ bulkhead → deadline.child(6 s) → source.search()
▼
normalize → sources (concurrent) → dedupe → similarity → relevance → signal → report
- Normalisation (
normalize.ts): NFKD, accents andßfolded, lowercase, punctuation and hyphens dropped, a leading article and trailing legal forms stripped. "The Velo-Raptor!" and "veloraptor" are the same key. One normaliser for every name the check touches — the query and every candidate. - Similarity (
similarity.ts): exact → normalised → edit distance (≤ 2) → Kölner Phonetik → shared dominant token → affix containment → bigram floor. The best rung wins and names itself asbasis, which is what the page shows beside the score. Bands mirror the spec: 100 / 100 / 94 / 88 / 81 / ~62. Nothing belowSIMILARITY_FLOOR(60) is shown. - Relevance:
nice-classes.tsmaps a category to Nice classes and grades a marksame·possibly·unrelated·unknown— unrelated is still shown, never dismissed.relevance.tsgrades a jurisdiction against the territory; an EU mark issamefor Germany, an international registrationoverlapping. - Signal (
signal.ts): explicit predicates. Strong = near-exact, live, same territory, same category. Potential = close mark in a related category, or near-exact with the category unknown, or an active exact company in the territory, or a registered exact domain plus web usage. Similar = anything above the floor.manualReviewRecommendedis set atpotentialand above, and whenever a trade mark source in scope was not searched — for a German territory that is always, because DPMA cannot be.
Sources
| Source | Kind | Access | What a miss means |
|---|---|---|---|
EUIPO (sources/euipo.ts) |
trademark | Official API; OAuth2 client credentials + X-IBM-Client-Id, saved as a source connection. Hosts are part of the connection because the Sandbox portal runs on its own api-sandbox / auth-sandbox hosts; the production hosts are the defaults. The Sandbox answers from EUIPO's sample register (a frozen snapshot with synthetic rows, not aligned with production) — right for the admin connection test, wrong for real answers. Production access is free: identity documents (passport/ID, or ID plus a company-register excerpt) e-mailed to docs.apiplatform@euipo.europa.eu; the Conditions of Use permit showing results publicly. Two RSQL queries: contained term (quoted when the name has spaces) and prefix. |
No EU trade mark or EU-designating international mark matched. |
GLEIF (sources/gleif.ts) |
company | Keyless JSON; full-text + fuzzy completions; 60 req/min. | No LEI holder by that name. Most companies have no LEI — this is a signal, not a register. |
RDAP (sources/rdap.ts) |
domain | IANA bootstrap (cached 24 h, static fallback); public servers the bootstrap omits by override — DENIC's pilot for .de, Identity Digital for .io, SWITCH for .ch; DNS delegation as evidence for .eu (no RDAP); lookup_unavailable for the rest. 200 → registered, 404 → not registered, else unknown. |
The domain is not registered at that registry. Says nothing about a mark. |
Web (sources/web.ts) |
web | Vendor seam: the first connected of Tavily then Brave, else credentials_missing. Exact-phrase query — Tavily additionally with exact_match, so pages merely about similar names do not count, and a German boost (country) for the German territory — ten hits, graded on host label and title. |
Nothing on the web's first page used the name. |
DPMA, TMview, WIPO, USPTO, Handelsregister, OpenCorporates (sources/manual.ts) |
trademark / company | Manual links only: no API, a paid contract, or terms that forbid scripted access. | Not searched. The coverage row says so. |
Every source declares manualUrl() and the coverage table shows it on every row — the
authoritative search is one click away whether the automated one ran or not.
Resilience and spend
A source that cannot answer becomes a coverage row, never an error response
(check.ts, coverageStatusFor). Around each live source, in this order:
readThroughon a shared cache policy — 24 h for registries, 1 h for domains — keyed by<source>:<territory>:<sha256(normalised name)>. Only successes are cached; a cached failure would replay an outage for a day. The envelope keeps the upstreamretrievedAt.- One
defineBreakerfor the domain, subject = source id: three failures in two minutes opens it for five; a 429 trips it forRetry-After. takeDailyQuota(quota.ts): per-source counters in Redis, sized to the free tiers inconfig.ts(DAILY_QUOTA). Fails open — the counter protects a bill, not a boundary.defineBulkheadper source (8 slots for RDAP's ten lookups, 4 elsewhere).deadline.child(SOURCE_CEILING_MS)inside the request's own budget: the fan-out takes 8 s of the 10 s hook budget and holds 700 ms back for the response.
The per-IP limiter (RATE_LIMIT_*, 10 per minute) runs before any of this, on both the
form action and the JSON route, with the honeypot in front of it on the form.
Privacy
The name a visitor types may be an unreleased brand. Therefore: POST bodies, never ?q=;
no log line carries the name (errors are logged by class and status, fetch-json.ts never
logs a URL or body); the cache key is a hash; no table stores a query; the report is
returned and forgotten. The privacy showcase and docs/stack/vendors.md list the upstreams
the name is sent to.
What the UI never says
- "Legally available", "safe to use", or any percentage that could read as legal risk.
- A similarity score without its basis.
- "No matches" without the coverage table beside it saying where — the closing note on the page spells out that it means "in the sources marked complete".
- A colour without a word: every level and status pairs its Badge or Alert with text.
Source connections
Vendor credentials are administrator-managed and persisted, never environment-based —
the same shape as the AI providers' ai.provider_connection
(docs/blueprint/ai/provider-routing.md). The admin connects each vendor under
Admin → Name check (/admin/name-check): enable it, enter EUIPO's client id and secret
or a search API key, test, save. .env carries nothing for this feature; ENCRYPTION_KEY
stays a deployment secret because the database cannot protect its own key.
| Piece | Where | What it owns |
|---|---|---|
Table name_check.source_connection |
db/schema/name-check/source-connection.ts |
One row per vendor (euipo, tavily, brave): enabled, client_id, secret_ciphertext, api_base, token_url, version, updated_at/by. Absent row = never configured. CHECKs: only EUIPO carries a client id or hosts; hosts are https://. |
| Reads/writes | db/name-check/source-connections.ts |
Ciphertext in, ciphertext out. Every write is a compare-and-swap on version. |
| Encryption | security/aes-gcm.ts (openSecret, KeyStatus) + security/encryption-key.ts |
The envelope the AI keys and Discord tokens use; openSecret answers none / ready / undecryptable instead of throwing. |
| Connections | name-check/connections.ts → resolveNameSourceConnections(rows, key) |
Opens each secret, decides configured once (enabled ∧ opened ∧ for EUIPO a client id), builds the NameSourceCredentials the sources receive as a closure — the plaintext is never a field. publicNameSourceConnection is the only shape that crosses to a client; security/load-leak-gate.test.ts refuses secretCiphertext in any client-facing file. |
| Composition | name-check/index.ts → loadNameSourceConnections() / loadNameSourceCredentials() |
The app-side doors: db + getEncryptionKey(), read at the start of every operation, so a save is live on the next check on every instance. The admin page uses the strict one (an unreadable table is an error it renders). The public check uses the forgiving one: an unreadable table or a rotated key becomes credentials_missing rows — the check never fails on its settings, and the log line names the error class, not a query. |
| Admin operations | name-check/connection-settings.ts |
Save / remove secret / test: seal → guarded write → best-effort audit (name_check.source.*, detail = version, changed fields, keyChange; never a secret). |
| Connection test | name-check/connection-test.ts |
The vendor's own search() with a fixed probe name under SOURCE_TEST_BUDGET_MS, one attempt, outside cache, quota and breaker, rate-limited per admin. Classified ok / invalid_credentials / rate_limited / timeout / unavailable / unknown; a wrong host is unavailable, not a wrong password, and a failure carries a secret-free detail naming the step and status (token endpoint: HTTP 401 = wrong secret, trademark search: HTTP 403 = no approved subscription yet). The EUIPO token cache is cleared first so a draft secret cannot ride the saved one's token. |
Testing
normalize, similarity, signal, relevance, quota and the source mappers are
unit-tested with fixtures; check.test.ts drives the orchestrator with fake sources —
partial failure, timeout, quota, credentials, cache hit, floor, territory scoping, the
onSourceSettled seam, and a spy proving the name never reaches console.error. The HTTP
clients themselves are not mocked-and-tested: the only realistic risk there is the remote
contract, which a mock cannot witness (see testing/strategy.md).
Emulating it
- Declare each upstream as a
NameSource— territories andmanualUrlfirst,searchsecond. A registry you cannot query still earns a row. - Map every thrown error class to exactly one coverage status in one place.
- Cache successes only; put the hash of the query in the key, never the query.
- Keep the similarity engine pure and fixture-tested; put the thresholds in
config.ts. - Derive the signal from predicates over evidence, and return reasons as codes so the page can explain in the reader's language.
- Rate-limit before the fan-out; quota per source; deadline per source; breaker per source.
- Keep vendor credentials in an administrator-managed table sealed under the deployment
key, opened once per operation into a closure — never in
.env, never on a field.
Later
- Progressive delivery:
checkName()already firesonSourceSettledper source; an NDJSON route (retrieval/ingest/streamis the precedent) can forward rows as they land. - Altcha escalation after repeated checks from one bucket (
verifyAltchaexists; adaptive gating does not yet). - Companies House and OpenCorporates as live sources once a key is worth its terms; DPMAconnectPlus if the contract is.
- A Vely tool over the same
checkName(), saved searches, monitoring for new filings.