Sloth-ninja makes Companies House a research desk

The fork puts company records, filing papers and a useful memory of prior searches in one place.

workflowsecurity

Sloth-ninja has built a dedicated Company Search panel around Companies House, the UK's official company register. It turns a stop-start lookup task into a repeatable research surface.

  • Company profiles with directors, people with significant control, and clearer visibility of former officers and ceased control roles.
  • Filing-document viewing from the research screen.
  • Starred and recent companies, so users can revisit a matter or repeat a check without starting over.
  • Safer document retrieval, with guardrails for externally hosted filings and oversized downloads.

The work also tightens status updates and awkward edge cases during longer searches.

So what Legal teams doing corporate background checks should care because the fork makes routine company research easier to repeat and safer to handle.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

18 commits from Sloth-ninja/JessicaOSS, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
bcbb4469 WS7: Company Search panel (Companies House) sloth-ninja 2026-07-19 ↗ GitHub
commit body
Backend: extract getCompanyBundle from the chat tool so the new
/companies routes (search, profile+officers+PSCs bundle, paginated
filing history) share one implementation; requireAuth + try/catch on
every handler, fixed friendly error details (401->409 key-missing code,
404/429 mapped, else generic 502); new researchLimiter
(RATE_LIMIT_RESEARCH_WINDOW_MINUTES/RATE_LIMIT_RESEARCH_MAX, documented
in .env.example and CLAUDE.md).

Frontend: Research > Company Search page (320px master list / flex-1
detail, Overview/Officers/PSCs/Filing history tabs per approved
mock-up), reusing PageHeader, TableToolbar, skeletons and the existing
CompanyPanel (new optional `section` prop); new FilingHistoryList
(lazy-fetched, 25/page, DD/MM/YYYY, Companies House register link);
Research sidebar group with Building2 icon; mikeApi chSearchCompanies /
chGetCompany / chGetFilingHistory; sessionStorage assistant-prefill
handoff ("Continue in Assistant" -> ChatInput initialValue).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e258501a Docs: BUILD_LOG entry for WS7 PR 2 (Company Search) sloth-ninja 2026-07-19 ↗ GitHub
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
b83a1e4d WS7 review fixes: company-number validation, profile type guard, key-precedence docs sloth-ninja 2026-07-19 ↗ GitHub
commit body
- /companies routes: reject company numbers that aren't strictly
  alphanumeric after normalisation (400, fixed detail) so crafted input
  with path/query metacharacters never reaches the Companies House URL;
  validateCompanyNumber exported + tested.
- company-search page: guard profile narrowing with typeof "object".
- Docs truth fix: key resolution is env-first with per-user BYO fallback
  (userApiKeys.ts seeds env keys first) - corrected CLAUDE.md env
  registry, BUILD_LOG wording and the route header comment. Precedence
  behaviour itself unchanged (open owner decision, PR #25 review).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
93622ea5 Merge origin/main (PR #24 Citation Checker) into ws7-company-search sloth-ninja 2026-07-19 ↗ GitHub
commit body
Conflict resolutions (keep-both throughout):
- AppSidebar.tsx: adopted #24's renderNavItem pattern; the Research group
  now lists both Company Search and Citation Checker (collapsible variant
  from this branch dropped in favour of the merged static group label).
- backend/src/index.ts: both limiters (researchLimiter, citationsLimiter)
  and both mounts (/companies, /citations) kept.
- mikeApi.ts: both the Companies House section and checkCitations kept.
- backend/.env.example: both rate-limit var blocks kept.
- CLAUDE.md: both module-map route entries merged into one row;
  RATE_LIMIT_* count now 16 covering research + citations vars.
- docs/BUILD_LOG.md: both entries kept, this branch's entry first;
  verification updated to the post-merge numbers (vitest 133/133).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a7b5ede3 Merge pull request #25 from Sloth-ninja/ws7-company-search Ezana.H 2026-07-19 ↗ GitHub
WS7: Company Search panel (Companies House)
70836776 Company search: surface ceased PSCs/resigned officers + view filing documents sloth-ninja 2026-07-28 ↗ GitHub
commit body
Two owner-reported company-search defects.

Defect 1 (frontend only, CompanyPanel - backs both the assistant side
panel and the /company-search tabs): the PSC renderer never read
`ceased_on`, so ceased PSCs displayed as current; resigned officers only
had inline text. Both now carry a neutral grey "Ceased"/"Resigned" pill
(shared Badge) + DD/MM/YYYY date, a de-emphasised row, active-first stable
ordering, and a muted "· N ceased/resigned" count in the section header.

Defect 2: filing-history rows had no way to open the underlying document.
New backend proxy GET /companies/:companyNumber/filing-history/
:transactionId/document walks the CH Document API chain (transaction →
document_metadata → signed content URL), streaming the PDF inline with a
25 MB guard and a host-check (SSRF defence) on the upstream-supplied URLs;
the key is dropped on the cross-origin 302 to S3 and never logged. Route
uses the sibling try/catch + companiesHouseErrorResponse/logAndRespond
pattern (guarantees a response with fixed safe details - the guarantee the
asyncHandler rule exists to provide). Frontend gains a per-item "View PDF"
affordance (blob → new tab, busy + inline-error states). Chat tool
description now tells the model to flag resigned/ceased status explicitly.

Tests: +6 lib (metadata→content helper: happy chain, 404 no-metadata,
404 no-content-link, oversize reject, off-host SSRF reject, empty-key
401) and +6 route (validateTransactionId + document route over a real
server with the CH client mocked). Backend 385/385 green; tsc + eslint
clean. No new deps, no migrations. UI screenshots pending (headless).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323dad3b Company-search saves migration: starred + recent companies table sloth-ninja 2026-07-28 ↗ GitHub
commit body
Owner-authorised 28/07/2026 (allowlist entry added by the owner).
Additive per-user table company_search_saves (hidden_workflows
precedent): starred flag + last_viewed_at recents ordering, RLS
enabled, browser-role grants revoked. schema.sql mirrored. Feature
code follows in a separate PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9d2b0f1c Merge pull request #49 from Sloth-ninja/company-search-saves-migration Ezana.H 2026-07-28 ↗ GitHub
Company-search saves migration: starred + recent companies
aed9d357 Harden CH filing-document proxy per review (SSRF, size guard, nits) sloth-ninja 2026-07-28 ↗ GitHub
commit body
Addresses independent review (REQUEST_CHANGES) of the filing-document path.

BLOCKER - bypassable host check: the `startsWith(DOCUMENT_API_BASE)` guard
on the metadata/content links passed for suffix-domain
(`...service.gov.uk.evil.com`) and userinfo (`...service.gov.uk@evil.com`)
spoofs; since Authorization is attached to the initial request, a tampered
link would exfiltrate the CH API key and give an SSRF primitive. Replaced
both checks with `isDocumentApiUrl` - `new URL(u)` + exact
`protocol === "https:" && host === "document-api.company-information.service.gov.uk"`.
Added regression tests for both exact attack URLs (rejected 404, no
key-attached fetch).

SHOULD-FIX - size guard buffered before checking: the post-`arrayBuffer`
byteLength check could balloon memory on a lying/chunked response. Now
streams the body with a running byte counter and aborts via AbortController
the moment the 25 MB cap is exceeded (Content-Length stays as a fast path).
Test: a mocked stream exceeding the cap is aborted and maps to the fixed
generic 502.

NITS: (1) allowlist the response Content-Type (application/pdf,
application/octet-stream) - anything else forced to octet-stream so an inline
disposition never renders untrusted upstream content; test added.
(2) FilingHistoryList: handle `window.open` returning null (pop-up blocked) -
revoke the blob URL immediately and show the inline error.

Docs: corrected the DURABLE_LESSONS entry to prescribe URL-parse + exact
protocol/host equality (naming both bypass shapes) instead of startsWith;
BUILD_LOG updated to reflect the hardened guard + streaming + allowlist.

Backend: tsc clean; vitest 388/388 (+3 lib tests). Frontend: tsc + eslint
clean. No new deps; no migrations touched; API key never logged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
d77f585a Merge remote-tracking branch 'origin/main' into company-search-status-filings sloth-ninja 2026-07-28 ↗ GitHub
# Conflicts:
#	docs/BUILD_LOG.md
b7e83974 Company-search saves: starred + recent companies sloth-ninja 2026-07-28 ↗ GitHub
commit body
Application code for the starred/recent companies feature over the
company_search_saves table (migration #49).

Backend: self-contained lib/companySearchSaves.ts (recordCompanyView with
snapshot refresh + 25-recent prune that never prunes starred rows;
setCompanyStar that inserts a not-yet-saved company from its snapshot;
listCompanySaves), all 42P01/42703-tolerant so an unmigrated production DB
degrades to empty lists / no-ops. Routes on the existing /companies router
(same requireAuth + research limiter, sibling try/catch + fixed generic
detail, caller id from the session): GET /companies/saves,
POST /companies/:companyNumber/view, PUT /companies/:companyNumber/star, with
exported validateViewBody / validateStarBody body validation.

Frontend: company-search rail shows Starred then Recent sections (star toggle
per row) when the search box is empty, a header star toggle for the open
company, best-effort fire-and-forget view recording on open, and optimistic
star updates with rollback. mikeApi getCompanySaves/recordCompanyView/
setCompanyStar + types.

Tests: +20 backend (upsert/snapshot/prune, star/unstar incl. insert-with-
snapshot, cross-user isolation, 42P01/42703 degradation, route validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9d2b1098 Merge pull request #48 from Sloth-ninja/company-search-status-filings Ezana.H 2026-07-28 ↗ GitHub
Company search: surface ceased PSCs/resigned officers + view filing documents
89de3ae8 Merge remote-tracking branch 'origin/main' into company-search-saves sloth-ninja 2026-07-28 ↗ GitHub
# Conflicts:
#	backend/src/routes/companies.test.ts
#	backend/src/routes/companies.ts
#	docs/BUILD_LOG.md
#	frontend/src/app/lib/mikeApi.ts
a7dcef61 Merge pull request #51 from Sloth-ninja/company-search-saves Ezana.H 2026-07-28 ↗ GitHub
Company-search saves: starred + recent companies
cd346c7e Company-search train: composed-range fix wave sloth-ninja 2026-07-28 ↗ GitHub
commit body
Four cross-module findings from the composed-range review of the
company-search train (none catchable per-PR):

1. Right-to-erasure gap: deleteUserAccountData now deletes the user's
   company_search_saves rows (42P01/42703-tolerant so a self-hosted DB that
   has not run migration 20260728_01 does not fail account deletion). Saves
   are user-level, so project cleanup paths are untouched.
2. SAR completeness: buildUserAccountExport now includes company_search_saves
   (all columns; missing table -> empty section via a tolerateMissing option
   on selectAll).
3. Star-error visibility: surface the fixed star-failed message under the
   detail-header StarToggle on company-search (not only in the empty rail).
4. Copy honesty: land-registry Price Paid footnote reports the real count
   ("Showing all N transaction(s)." / capped-at-25 wording) instead of a
   hardcoded "25 most recent".

The 42P01/42703 idiom is shared, not duplicated: isMissingTableOrColumn is
exported from companySearchSaves.ts. New backend tests cover deletion +
export inclusion and the missing-table tolerance (+7). Backend tsc clean,
vitest 442 passed; frontend tsc + eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27f51e8b Merge pull request #52 from Sloth-ninja/company-search-train-fixes Ezana.H 2026-07-28 ↗ GitHub
Company-search train: composed-range fix wave
33a7eb26 Company-search train close-out: status refresh sloth-ninja 2026-07-28 ↗ GitHub
commit body
CLAUDE.md Current status updated: #48-#52 merged, deployed and
verified live; 20260727_01 run in production (firm features active);
20260728_01 saves migration still owner-pending in prod; Quill
capability verification recorded, spike gated on QUILL_DOMAIN.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
02c69cf4 Merge pull request #53 from Sloth-ninja/company-search-closeout Ezana.H 2026-07-28 ↗ GitHub
Company-search train close-out: status refresh

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-858.md from inside the repo you want the changes in.

⬇ Download capture-thread-858.md