Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
# OpenSpec Change: site-icons
**Package**: `@suaveplan/site-icons`
**Status**: PROPOSED
**Type**: new-package
## Why
Vaulted needs to display website icons next to vault login items. No maintained standalone library combines multi-source favicon discovery, size-aware selection, domain-normalized caching, and MIME validation in a single tree-shakeable package. Hand-rolling this inside the Vaulted app would duplicate concerns and prevent reuse across browser extensions and desktop.
## What changes
- ADDED: `SiteIconFetcher` — orchestrates multi-source discovery, caching, and fallback
- ADDED: `fetchSiteIcons(domain)` — fetches icons from all known sources for a domain
- ADDED: `selectBestIcon(candidates, options?)` — size-aware ranking and selection
- ADDED: `IconSource` — discriminated union of discovery sources (`favicon`, `appleTouchIcon`, `manifestIcon`, `openGraph`)
- ADDED: `SiteIconResult` — `{ source: IconSource; url: string; size: IconSize | null; mimeType: string; data: Uint8Array }`
- ADDED: `IconSize` — `{ width: number; height: number }`
- ADDED: `SiteIconError` extends `SuaveplanError`
- ADDED: `FetchTimeoutError` extends `SiteIconError` (code `SITE_ICON.TIMEOUT`)
- ADDED: `InvalidIconError` extends `SiteIconError` (code `SITE_ICON.INVALID`)
- ADDED: `NotFoundError` extends `SiteIconError` (code `SITE_ICON.NOT_FOUND`)
- ADDED: Zod schemas for all boundary types
- ADDED: OTel counters `site_icons.fetch.total` (by `source`, `outcome`) and histogram `site_icons.fetch.duration_ms`
## Out of scope
- Image resizing / downscaling manipulation — deferred to `@suaveplan/compress-image`
- Build-time app icon generation — `@suaveplan/iconz` already covers this
- DOM-parsing HTML for arbitrary `<link rel="icon">` discovery beyond the well-known sources listed above (may be added later)
- ICO file multi-image extraction (treat ICO as opaque blob)
## Gap analysis: sub-standard → enterprise
Typical favicon implementations fetch a single hard-coded `/favicon.ico` with no caching strategy, no size-aware selection, and no graceful fallback when sources are missing or return invalid data. This leads to broken UI states, excessive network requests, and unpredictable memory usage from unvalidated image blobs. Enterprise-grade code must treat icon discovery as a resilient pipeline with normalized domain keys, TTL-bound caching, MIME validation, and a ranked fallback chain.
## Security model
- **Threat model**: Malicious domains serving oversized or malformed image payloads intended to exhaust memory or exploit image-parser vulnerabilities; MITM interception of icon traffic to fingerprint user vault contents.
- **Privacy guarantees**: No vault metadata or credential identifiers are transmitted during icon discovery. Domain names are normalized but not obfuscated; requests are made without authentication cookies.
- **Input validation**: All fetched blobs are inspected against an allow-list of MIME types and size bounds before being retained in memory or cache.
- **Side-channel resistance**: Cache lookups use constant-time key comparison where feasible; request timing is not correlated with vault item count.
- **Key material handling**: Not applicable — this package does not handle cryptographic keys.
## Performance SLOs
- **Latency**: P95 icon fetch (cache miss) < 1.5s; P95 cache hit < 5ms.
- **Throughput**: Support 1,000 concurrent domain lookups without head-of-line blocking.
- **Memory bounds**: In-flight blob memory capped at 10 MB per `SiteIconFetcher` instance; cached entries bounded by LRU count and TTL.
- **Bundle size budget**: Core fetcher + selector < 8 kB gzipped; optional cache adapter tree-shakeable.
## Observability plan
- **OTel spans/metrics**: Counter `site_icons.fetch.total` by `source` and `outcome`; histogram `site_icons.fetch.duration_ms`; cache hit/miss ratio gauge.
- **Structured logging**: JSON logs at `warn` for fetch timeouts, `error` for MIME validation failures, `debug` for cache hits.
- **Error telemetry**: Distinct error codes (`TIMEOUT`, `INVALID`, `NOT_FOUND`) tagged with domain (hashed for privacy) and source.
- **Health checks**: Expose `SiteIconFetcher.isHealthy()` returning false if > 50% of sources are consistently failing.
## Dependency audit
- **Runtime deps**: `@suaveplan/errors`, `@suaveplan/otel`, `@suaveplan/domain-normalizer` (domain key normalization), `@suaveplan/cache-browser` (TTL-bound icon caching), `@suaveplan/mime` (image MIME validation), and platform `fetch` / `crypto`.
- **Supply-chain risk**: All external image parsing is deferred to platform APIs; no third-party image-decoding libraries.
- **Tree-shakeability**: `SiteIconFetcher`, `selectBestIcon`, cache adapters, and fallback chains are individually tree-shakeable.
- **License**: All code and bundled assets are MIT-licensed; no copyleft dependencies.
## Operational readiness
- **Graceful degradation**: If all sources fail, the fallback chain yields a transparent 1×1 pixel or the consumer-provided default; UI never blocks.
- **Resource limits**: Configurable per-request timeout, global concurrency limit, and max blob size.
- **Error recovery**: Exponential backoff for repeated source failures; circuit-breaker pattern if a source is persistently unhealthy.
- **Backpressure**: Concurrent fetches limited by semaphore; excess requests queued with a max queue depth and drop policy.
- **Version compatibility**: Public API surface frozen after v1; additive-only changes; deprecated APIs supported for two major versions.
## Verification approach
- **100% coverage target**: All exported functions, branches, and error paths instrumented in test.
- **Property-based tests**: Domain normalization, size ranking, and cache key generation tested with fast-check invariants.
- **Load/stress criteria**: Sustained 1,000 concurrent fetches for 60s without memory growth > 10%.
- **Security audit checklist**: MIME bypass attempts, oversized payload handling, cache poisoning via domain normalization edge cases.
- `src/fetcher/fetcher.test.ts` — covers multi-source fetch, timeout, abort, MIME validation
- `src/selector/selector.test.ts` — covers size-aware ranking, null-size handling, exact match
- `src/cache/cache.test.ts` — covers cache hit, miss, TTL expiry, domain-normalized keys
- `src/fallback/fallback.test.ts` — covers fallback chain ordering, exhaustion
- `src/errors/errors.test.ts` — covers error construction, code narrowing, context shapes
- `src/errors/errors.spec-conformance.test.ts` — pins strict-mode behaviors and `instanceof` chains
- `tests/dep-graph.test.ts` — pins dependency invariants (no React, no Node-only deps)
## Migration impact
None — additive only.
## Impact
### Downstream consumers
No dependents (verified by `bunx turbo ls --filter=...@suaveplan/site-icons`).
### Breaking changes
None — additive only.
### Workspace dep prerequisites
- `@suaveplan/errors`
- `@suaveplan/otel`
- `@suaveplan/domain-normalizer`
- `@suaveplan/cache-browser`
- `@suaveplan/mime`

View File

@@ -0,0 +1,196 @@
# Capability Spec Delta: site-icons
**Package**: `@suaveplan/site-icons`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
## ADDED Requirements
### Requirement: Domain normalization before fetch
`fetchSiteIcons(domain)` SHALL normalize the input domain via `@suaveplan/domain-normalizer` before initiating any network request. Protocols, paths, query strings, and port numbers MUST be stripped. Internationalized domain names MUST be converted to Punycode.
#### Scenario: URL with path and query
- **WHEN** `fetchSiteIcons("https://example.com/login?redirect=/dashboard")` is called
- **THEN** the effective domain used for all fetches is `example.com`
#### Scenario: Unicode domain
- **WHEN** `fetchSiteIcons("https://münchen.example/")` is called
- **THEN** the effective domain is the Punycode representation `xn--mnchen-3ya.example`
**Verified by:** `src/fetcher/fetcher.test.ts::"normalizes URL with path and query to bare domain"` AND `src/fetcher/fetcher.test.ts::"converts unicode domain to punycode before fetching"`
---
### Requirement: Multi-source icon discovery
`fetchSiteIcons` SHALL attempt to discover icons from the following sources in parallel:
1. `https://{domain}/favicon.ico`
2. `https://{domain}/apple-touch-icon.png`
3. `https://{domain}/manifest.json` — parsed for `icons` array entries
4. Open Graph image from `https://{domain}/` — parsed for `<meta property="og:image">`
Each source that returns an HTTP 200 with a valid image MIME type MUST produce a `SiteIconResult`. Sources that return 404 or non-image MIME types MUST be silently excluded.
#### Scenario: favicon.ico exists
- **WHEN** `fetchSiteIcons` is called and `/favicon.ico` returns 200 with `image/x-icon`
- **THEN** the result array includes a `SiteIconResult` with `source: { kind: 'favicon' }`
#### Scenario: manifest with multiple icons
- **WHEN** `fetchSiteIcons` is called and `/manifest.json` returns a JSON object with `icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }]`
- **THEN** the result array includes a `SiteIconResult` with `source: { kind: 'manifestIcon', src: '/icon-192.png', sizes: '192x192' }`
#### Scenario: non-image response excluded
- **WHEN** `fetchSiteIcons` is called and `/favicon.ico` returns 200 with `text/html`
- **THEN** no `SiteIconResult` with `source.kind === 'favicon'` is present in the results
**Verified by:** `src/fetcher/fetcher.test.ts::"includes favicon result when favicon.ico returns image mime type"` AND `src/fetcher/fetcher.test.ts::"parses manifest icons into separate results"` AND `src/fetcher/fetcher.test.ts::"excludes non-image responses from results"`
---
### Requirement: Size-aware selection and sorting
`selectBestIcon(candidates, options?)` SHALL rank candidates by proximity to the requested size (default 64×64). Candidates with explicit `size` metadata MUST be preferred over candidates with `null` size. If `options.preferLarger` is `true`, the closest candidate that is equal to or larger than the target MUST be chosen; otherwise the closest by absolute difference MUST be chosen.
#### Scenario: Exact size match preferred
- **WHEN** candidates include 32×32, 64×64, and 128×128 icons and target size is 64×64
- **THEN** `selectBestIcon` returns the 64×64 candidate
#### Scenario: Null-size candidates deprioritized
- **WHEN** candidates include one icon with size `null` and one with size 32×32
- **THEN** `selectBestIcon` returns the 32×32 candidate
#### Scenario: Prefer larger fallback
- **WHEN** `options.preferLarger` is `true`, target is 64×64, and candidates are 32×32 and 128×128
- **THEN** `selectBestIcon` returns the 128×128 candidate
**Verified by:** `src/selector/selector.test.ts::"returns exact size match when available"` AND `src/selector/selector.test.ts::"deprioritizes candidates with unknown size"` AND `src/selector/selector.test.ts::"prefers larger candidate when preferLarger is true"`
---
### Requirement: Caching via cache-browser
Successfully fetched and validated icons MUST be stored in `@suaveplan/cache-browser` using a domain-normalized cache key (`site-icon:{domain}:{sourceKind}:{url}`). The default TTL MUST be 7 days. On subsequent calls, cached entries MUST be returned without network activity.
#### Scenario: Cache hit returns cached data
- **WHEN** `fetchSiteIcons("example.com")` is called and a valid cached entry exists
- **THEN** no network request is made and the cached `SiteIconResult` is returned
#### Scenario: Cache miss performs fetch
- **WHEN** `fetchSiteIcons("example.com")` is called and no cached entry exists
- **THEN** network requests are performed and the result is stored in cache
#### Scenario: Expired cache entry ignored
- **WHEN** a cached entry exists but its TTL has expired
- **THEN** the entry is treated as a cache miss
**Verified by:** `src/cache/cache.test.ts::"returns cached result without network on cache hit"` AND `src/cache/cache.test.ts::"stores result in cache after successful fetch"` AND `src/cache/cache.test.ts::"treats expired entry as cache miss"`
---
### Requirement: Fallback chain when sources fail
If all parallel discovery sources fail (non-2xx, non-image MIME type, or network error), `fetchSiteIcons` MUST attempt a sequential fallback chain:
1. `https://{domain}/favicon.ico`
2. `https://{domain}/apple-touch-icon.png`
3. `https://www.google.com/s2/favicons?domain={domain}&sz=128`
If the fallback chain is also exhausted, `fetchSiteIcons` MUST throw `NotFoundError` (code `SITE_ICON.NOT_FOUND`) with `context.attemptedSources` listing every URL tried.
#### Scenario: Primary sources fail, Google fallback succeeds
- **WHEN** all primary sources return 404 and the Google favicon service returns 200 with an image
- **THEN** the result includes the Google-sourced icon
#### Scenario: Complete exhaustion throws
- **WHEN** all primary sources and all fallback sources return 404
- **THEN** `fetchSiteIcons` throws `NotFoundError` with `context.attemptedSources` containing every attempted URL
**Verified by:** `src/fallback/fallback.test.ts::"uses google favicon fallback when primary sources fail"` AND `src/fallback/fallback.test.ts::"throws NotFoundError when all sources including fallbacks fail"`
---
### Requirement: MIME type validation via mime
Every icon response MUST have its Content-Type validated via `@suaveplan/mime`. Only types whose category is `image` MUST be accepted. Responses with missing or non-image Content-Type headers MUST be rejected.
#### Scenario: Valid image MIME accepted
- **WHEN** a response returns `Content-Type: image/png`
- **THEN** the icon is accepted and returned
#### Scenario: Non-image MIME rejected
- **WHEN** a response returns `Content-Type: text/plain`
- **THEN** the source is excluded from results
**Verified by:** `src/fetcher/fetcher.test.ts::"accepts image/png response as valid icon"` AND `src/fetcher/fetcher.test.ts::"rejects text/plain response as invalid icon"`
---
### Requirement: Timeout and abort signal support
All network operations MUST respect an optional `AbortSignal`. If the signal aborts, all in-flight fetches MUST be cancelled and the promise MUST reject with a structured abort error. A per-source timeout (default 5000 ms) MUST be enforced; on timeout `FetchTimeoutError` (code `SITE_ICON.TIMEOUT`) MUST be thrown with `context.url` and `context.timeoutMs` populated.
#### Scenario: Abort signal cancels in-flight fetches
- **WHEN** `fetchSiteIcons` is called with an `AbortSignal` that aborts mid-flight
- **THEN** the promise rejects and no partial results are returned
#### Scenario: Per-source timeout throws FetchTimeoutError
- **WHEN** a source takes longer than the configured timeout
- **THEN** `FetchTimeoutError` is thrown for that source and the fallback chain continues
**Verified by:** `src/fetcher/fetcher.test.ts::"rejects when abort signal fires mid-flight"` AND `src/fetcher/fetcher.test.ts::"throws FetchTimeoutError when source exceeds timeout"`
---
### Requirement: Structured errors
All thrown errors MUST extend `SiteIconError` which extends `SuaveplanError`. Error codes MUST be registered via `registerSiteIconErrorCodes()` called as a side-effect of importing `src/index.ts`. The following codes MUST be defined:
- `SITE_ICON.TIMEOUT` — a source fetch exceeded the timeout
- `SITE_ICON.INVALID` — MIME type or response body failed validation
- `SITE_ICON.NOT_FOUND` — no icon could be discovered for the domain
**Verified by:** `src/errors/errors.test.ts::"all error classes extend SiteIconError"` AND `src/errors/errors.spec-conformance.test.ts::"error codes are registered on module import"`
---
### Requirement: Property-based and contract tests
The following fast-check properties MUST be included:
1. **Domain normalization idempotence**: for any string input, normalizing twice yields the same domain as normalizing once.
2. **Selector monotonicity**: adding a better-sized candidate to the list never causes `selectBestIcon` to return a worse-ranked candidate.
**Verified by:** `src/fetcher/fetcher.test.ts::"domain normalization is idempotent for arbitrary strings"` AND `src/selector/selector.test.ts::"adding a better candidate never worsens selection"`
---
### Requirement: Benchmarks and 100% coverage
Mitata benchmarks MUST be provided under `src/__benchmarks__/`:
- `fetchSiteIcons` with cache hit — 10 000 iterations
- `selectBestIcon` over 50 candidates — 100 000 iterations
Vitest thresholds MUST be set to 100/100/100/100. `sideEffects: false` MUST be declared in `package.json`.
**Verified by:** `src/__benchmarks__/fetch.bench.ts` AND `src/__benchmarks__/select.bench.ts` AND `vitest.config.ts`

View File

@@ -0,0 +1,74 @@
# Tasks — site-icons
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `MIGRATION.md` Section 4.3 for source requirements (skip enterprise — genesis-original)
- [ ] 1.2 Read `openspec/specs/site-icons/spec.md` if it exists
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [ ] 1.5 Confirm no `## Source mapping` section (genesis-original)
- [ ] 1.6 Draft `specs/site-icons/spec.md` from `openspec/templates/spec-delta.md`
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [ ] 2.1 Scaffold `packages/universal/site-icons/` with `package.json`, `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`
- [ ] 2.2 Implement `src/error-codes.ts` — `SITE_ICON.*` codes via `defineCodes`
- [ ] 2.3 Implement `src/errors/site-icon-error.ts`, `fetch-timeout-error.ts`, `invalid-icon-error.ts`, `not-found-error.ts`
- [ ] 2.4 Implement `src/types.ts` — `IconSource`, `IconSize`, `SiteIconResult`, `FetchOptions`
- [ ] 2.5 Implement `src/schemas.ts` — Zod schemas for all boundary types
- [ ] 2.6 Implement `src/fetcher/fetcher.ts` — `SiteIconFetcher` with multi-source discovery
- [ ] 2.7 Implement `src/selector/selector.ts` — `selectBestIcon` with size-aware ranking
- [ ] 2.8 Implement `src/cache/cache.ts` — domain-normalized caching via `@suaveplan/cache-browser`
- [ ] 2.9 Implement `src/fallback/fallback.ts` — ordered fallback chain logic
- [ ] 2.10 Implement `src/telemetry/meters.ts` — OTel counters and histograms
- [ ] 2.11 Wire subpath exports in `package.json` for every feature module
## 3. Tests
- [ ] 3.1 Unit tests at `src/fetcher/fetcher.test.ts` covering every scenario
- [ ] 3.2 Unit tests at `src/selector/selector.test.ts` covering every scenario
- [ ] 3.3 Unit tests at `src/cache/cache.test.ts` covering every scenario
- [ ] 3.4 Unit tests at `src/fallback/fallback.test.ts` covering every scenario
- [ ] 3.5 Spec-conformance tests at `src/errors/errors.spec-conformance.test.ts` pinning type-level / observability / strict-mode scenarios
- [ ] 3.6 Integration / contract tests where the spec demands them
- [ ] 3.7 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
- [ ] 3.8 Property-based tests (fast-check): cache round-trip, selector monotonicity
## 4. Documentation
- [ ] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
- [ ] 4.2 README links every co-located `.md` under `## Modules`
- [ ] 4.3 Update `CLAUDE.md` package listing if this is a new package or removes a package
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/site-icons` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/site-icons` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/site-icons` — exit 0, no `error` findings
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/site-icons` — green
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
- [ ] 6.2 Zod schemas wired at every declared boundary
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path
- [ ] 6.4 Strict-mode behaviors tested both on and off
- [ ] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 6.6 Lifecycle dedup tested where claimed (in-flight Promise, idempotent set/load)
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/site-icons/spec.md`
- [ ] 7.2 Move `openspec/changes/site-icons/` → `openspec/archive/<yyyy-mm-dd>-site-icons/`
- [ ] 7.3 Refresh `MANIFEST.md` if package set or deps changed