# Implementation Hints and Learnings

## Derived state must name its invalidation trigger (2026-07-25)

Three separate production failures on one day had the same shape: **state was
mutated in one layer and the mirrored state in another layer was left behind.**

1. `DomainDoc` deleted → the generated apex NS handler stayed registered in the
   embedded smartdns server, still answering with the `aa` flag until dcrouter was
   restarted, while record queries already answered REFUSED.
2. A certificate was successfully re-issued and stored → SmartProxy's private
   per-domain `certFailureAt` cooldown still gated the domain, so every route
   reapply skipped it and the proxy kept serving the expired certificate. No
   error, no log (the sweep returns before its summary line when every domain is
   skipped).
3. A null sentinel was translated for validation but persisted raw.

### The rule

> Every cache, negative cache, or derived registration must name, in code, the
> mutation that invalidates it — and the invalidation must run inside the same
> operation that mutates the source of truth.

Corollaries, all of which are review-blocking:

- A registration is torn down by the same operation that removes what it was
  derived from. Track what you actually registered; do not recompute "would I
  have registered this?" at teardown time. `DnsServer.unregisterHandler(pattern,
  types)` removes **every** handler for that pattern/type pair. `DnsManager`
  retains every handle returned by `registerHandler()` under a per-domain
  `name|type` key, tagged `persisted` or `generated-default`, and invokes only
  those exact handles during teardown or server replacement.
- A negative cache is invalidated by the event that **resolves its cause**, not
  only by elapsed time. Time-only expiry plus a skip-while-cooling-down rule is a
  deadlock: the sweep that would clear the entry is the sweep the entry blocks.
- **Issued is not served.** An operation that advances one layer must verify the
  layer that actually serves traffic before reporting success. The certificate
  overview reports `expiryDate` (issued/stored) and `served` (what the Rust engine
  presents) separately and downgrades the combined `status` to `failed` with
  `servedMismatch` when they disagree; `reprovisionCertificateDomain` refuses to
  return success while the engine still serves an invalid certificate.
- If the layer that owns the derived state exposes no invalidation API, that is
  the defect. A periodic sweep that blindly clears caches, or a shortened TTL, is
  a workaround that hides it — fix the owning layer. Reaching into another
  package's private state (e.g. `smartProxy.bridge`) is an internal bypass and
  must not ship.

### Known open instance (needs an upstream change)

`@push.rocks/smartproxy` (27.20.0) keeps `certValidity` and `certFailureAt` as
**private** in-memory maps and exposes no per-domain invalidation. The only two
public methods that look relevant, `provisionCertificate(routeName)` and
`renewCertificate(routeName)`, both go through the Rust ACME bridge, which
SmartProxy force-disables whenever `certProvisionFunction` is set — so a consumer
using `certProvisionFunction` has no supported way to clear the cooldown or
hot-load a certificate for one domain. Smallest correct upstream addition:
`invalidateCertProvisionCooldown(domain: string): boolean` (plus, ideally, a
`'skip'` member of `TSmartProxyCertProvisionObject` so a consumer can decline an
attempt without SmartProxy recording it as a failure and re-arming the cooldown).
Until that ships, the only operator remedy is toggling the single affected route
off and on; a dcrouter restart costs 30–60 s of total public outage.

## DNS authority is proven by delegation, not declared (2026-07-25)

`dnsScopes` was the last un-migrated bootstrap option in an otherwise DB-driven
router: read once from the OCI container config at startup, with no mutation
path, no watcher, no reload. Claiming a newly delegated zone therefore required a
restart — 30–60 s of total public outage.

**The fix is not a `setDnsScopes` mutation.** The domain-ownership predicate
accepts scope coverage as *proof* precisely because only a deploy can change it.
An editable declared list is self-assertion, and would have re-opened the exact
hole that `createDcrouterDomain` had. Instead a zone earns authority by its
**public delegation naming our `dnsNsDomains`** — unforgeable through the ops API,
because you cannot repoint a domain's NS records without controlling the domain.
That is also the comparison that was missing: declared authority versus real
delegation, made the mechanism rather than an audit bolted on afterwards.

- `ts_interfaces/data/dns-authority.ts` — the contract.
- `ts/db/documents/classes.dns-authority.doc.ts` — singleton doc holding only
  *verified* zones, following the `AcmeConfigDoc` pattern.
- `ts/dns/manager.dns-authority.ts` — probe, mutations, drift audit.
- `ts/opsserver/handlers/dns-authority.handler.ts` — `dns-authority:read` /
  `dns-authority:write`, admin identity required on write.

**Precedence: there is none, because there is one source.** `dnsScopes` is gone
— the option, the `DCROUTER_DNS_SCOPES` env override, and the bootstrap floor it
contributed. The authority set is the delegation-verified zones in the database
and nothing else.

The union it replaced was a floor: always in effect, changeable only by a
redeploy, and **not revocable through the API**. Those are the same three
properties that produced the outage this path exists to prevent, so keeping them
as a safety net kept the disease as the cure.

Removing the floor makes the *unreadable database* case load-bearing, which is
what the original union was really defending against. The defence was right; the
mechanism was not. Three situations, three answers — and the whole point is that
they are three, not two:

| Situation | Authority set | Behaviour |
| --- | --- | --- |
| document missing | known-empty | claim nothing, log at `error`, DNS server still starts |
| document present, zero zones | known-empty | same, different message — somebody revoked everything, and that is an instruction |
| document unreadable | **unknown** | `start()` throws; `DnsServer` depends on `DnsManager`, so it does not come up claiming nothing |

Rendering an unknown as "claim nothing" is precisely how a transient database
fault would take every zone off the air. Fail closed on *authority*; never
silently on *service*. With an empty set the DNS server still binds :53, still
serves DoH, still runs the private-route overlay, and picks a zone up the moment
it is verified — REFUSED is a fast, honest "not ours", where an unbound port is
a timeout.

**The probe must not use the system resolver.** `smartdns.getNameServers()` calls
`dns.resolveNs`, i.e. the system resolver, which on a dcrouter host may be
dcrouter itself — it would answer with the very NS records we generated, making
the probe self-confirming. Use
`new plugins.smartdns.dnsClientMod.Smartdns({ strategy: 'doh', allowDohFallback: false })`
and `queryRecords(zone, 'NS')`: one DNS-over-HTTPS attempt against a public
resolver, no system fallback.

**Three verdicts, never two.** `delegated` / `not-delegated` / `undeterminable`.
A timeout is not evidence that a zone is not ours. A mutation refuses on
`undeterminable` (never assume either way); the startup drift audit *skips* it.

**The drift audit is advisory and never mutates.** It reports three directions —
`delegated-but-unclaimed` (a live zone we could serve but do not),
`claimed-but-not-delegated` (authority we still assert after delegation moved
away), and `verified-but-unhosted` (authority with no `DomainDoc`-backed records
or generated apex NS). It must not revoke: a resolver blip at boot would
otherwise take every zone off the air, which is the outage this path exists to
avoid.

### Deploy sequence — seeding is mandatory, not advisable

Startup ordering is already correct: the service chain is `DcRouterDb` →
`DnsManager` → `SmartProxy` → `DnsServer`, and `DnsAuthorityManager.start()`
(which loads the stored verified zones) runs inside `DnsManager`'s `withStart`,
strictly before `dnsServerRuntime.setup()` reads the set or registers any
handler. So the stored set is applied on the **first** registration pass —
restart itself introduces no reconciliation gap.

**Seed before the first restart on this trio.** With `dnsScopes` gone there is no
floor left to fall back on: a zone that is not in `DnsAuthorityDoc` when the new
build starts is REFUSED outright, and that now includes the zone that used to be
declared in `dnsScopes` and was the only healthy one. Insert the singleton
`DnsAuthorityDoc` with `settingsId: 'dns-authority-settings'`, a `verifiedZones`
array containing one `{ zone, origin: 'verified', verifiedAt,
observedNameservers, verifiedBy }` entry per delegated zone, plus `updatedAt` and
`updatedBy`, *before* starting the new build. The first startup then has proof
already. The advisory startup drift audit re-probes and reports evidence that no
longer holds; it never corrects the seed automatically, so an operator must
re-run the seed or use the authority mutation API.

The production database is an embedded single-writer `LocalSmartDb` reached over
a Unix socket in `os.tmpdir()`, so it is not reachable off-host and the seed must
run on the dcrouter host while no dcrouter process owns it. The complete first
18.8.0 sequence is:

1. Stop 18.7.1, suppress automatic restart, and prove no dcrouter process remains.
2. Take and verify an offline backup of the complete runtime tree and configured
   data root (normally `~/.serve.zone/dcrouter`).
3. Seed the authority singleton while dcrouter remains stopped.
4. Start exactly one 18.8.0 runtime and verify its 18.8.0 migration ledger,
   sparse DNS ownership index, authority drift, persisted data, and direct NS,
   SOA, and AAAA answers.
5. Restore automatic service management only after those checks pass.

`.nogit/debug/seed-dns-authority.ts` is the deployment-local convenience tool:
it probes every zone over DoH first and writes nothing on an unproven zone, and
it is re-runnable (it replaces the verified set with what it just proved, so
dropping a zone from the list drops it from authority).

SmartDB 2 must never open a data root that SmartDB 5 has written. Rollback is
therefore restore-only: stop 18.8.0, restore the exact pre-upgrade runtime tree
and offline data backup, and only then start 18.7.1.

That script lives under `.nogit/` and is therefore **not committed** — it is a
one-off operational tool for this specific upgrade, not product code, and it is
deliberately not a smartmigration step (see the next section for why). So it
cannot be the only record of the step: the changelog spells out the complete
document shape, including the `verifiedZones` array, and the same result is
reachable through `verifyDnsAuthorityZone` after startup with no restart. The
script is a convenience, not a dependency.

**If a zone was missed** — claim it after startup, no restart needed. Order:

1. Confirm the manager loaded: `DnsAuthorityManager: N delegation-verified zone(s) loaded`.
   An empty set logs at `error` instead, naming the remediation.
2. Read the startup drift audit. Every `delegated-but-unclaimed` line is a zone
   delegated to us that we are refusing to serve — that is the worklist.
3. Per zone, `probeDnsAuthorityZone` first (read-only, no mutation), then
   `verifyDnsAuthorityZone` only if the verdict is `delegated`.
4. `getDnsAuthority` — expect one `verified` entry per claimed zone, each
   carrying `observedNameservers`.
5. `getDnsAuthorityDrift` — expect `[]`.
6. Query each claimed zone's apex NS directly against our nameserver and expect an
   authoritative answer naming `dnsNsDomains`. Query `AAAA` and `SOA` too, not
   just `A`: a zone missing from `authoritativeZones` answers `A` and REFUSES the
   rest, which is the defect this work fixes and the only query pattern that
   distinguishes it.
7. `getMergedRoutes` — expect no `unverified-domain-ownership` warnings.
8. `getCertificateOverview` — expect no entry with `servedMismatch: true`.

Healthy is: drift empty, every claimed zone answering `aa` for its apex NS, no
ownership route warnings, no served/stored certificate mismatch.

A `verdict: 'undeterminable'` means the host could not reach a public DoH
resolver. That is an egress problem to fix, not something to retry blindly — and
the zone stays unserved until it is resolved, which is the correct fail-closed
behaviour.

### `authoritativeZones` is reconciled on a running server

An earlier revision recorded this as a known limitation: smartdns takes
`authoritativeZones` in the `DnsServer` constructor, so a zone verified at
runtime did not join it until the next restart, and it also meant the `DnsServer`
service only registered when bootstrap `dnsScopes` was non-empty — a
verified-only configuration never started the DNS server at all. Both are fixed.

It was not a cosmetic limitation. **`authoritativeZones` decides the response
kind, and it is the entire production defect.** smartdns answers a name inside a
configured zone and REFUSES a name outside every configured zone — *even when a
handler is registered and answers other qtypes for that exact name*. In
production that produced, from `212.95.99.130`:

```
social.io    A=NOERROR (aa)  AAAA=REFUSED  SOA=REFUSED
hard.global  A=NOERROR (aa)  AAAA=REFUSED  SOA=REFUSED
central.eu   A=NOERROR (aa)  AAAA=NOERROR  SOA=NOERROR   <- the only declared zone
```

Public recursives turn the REFUSED arm into SERVFAIL — `AAAA social.io` SERVFAILs
on 1.1.1.1, 8.8.8.8 and 9.9.9.9 — and `curl` does a dual A+AAAA lookup and blocks
on it, so dual-stack clients see a degraded site. Registering handlers for a
verified zone without moving this set would have reproduced the identical defect
for every runtime-verified zone.

**How it is reconciled without a restart.** SmartDNS 8.2 copies constructor
options and exposes `setAuthoritativeZones()`. `DnsServerRuntime` calls that
setter on every changed database authority set and updates its local snapshot
only after the call succeeds. No listener is recreated, so UDP/TCP 53 stays up.

`dnssecZone` cannot be reconciled the same way — it is baked into the Rust config
at start — so it is taken from the boot-time set, which is **sorted** and
therefore stable across restarts rather than dependent on database insertion
order. When the set is empty the required keying zone becomes
`no-authority.invalid`. An explicit empty `authoritativeZones` array in SmartDNS
8.2 claims no zone and does not fall back to `dnssecZone`; `.invalid` remains a
safe DNSSEC keying sentinel.

### Why removing `dnsScopes` needs no smartmigration step

`AGENTS.md` requires DB schema migrations to live exclusively in
`ts_migrations/index.ts`. This change adds none, deliberately, and the reason is
worth stating so its absence does not read as an oversight:

- **Nothing in the database changes shape.** `DnsAuthorityDoc` is untouched. The
  only type change is narrowing `TDnsAuthorityZoneOrigin` from
  `'bootstrap' | 'verified'` to `'verified'`, and no stored document ever
  carried `'bootstrap'` — `persist()` only ever wrote `verifiedZones`, and
  `verifyZone()` is the only thing that builds an entry. The bootstrap entries
  existed solely as a read-time projection inside `getSettings()`.
  `DnsAuthorityDoc` already shipped in 18.7.1, and this change does not alter its
  persisted shape.
- **Seeding from `dnsScopes` would forge the proof.** A migration step *could*
  read it — `createMigrationRunner` runs in-process at startup and already takes
  deployment-derived seeds — but a `dnsScopes` entry carries no delegation
  evidence. Persisting one as `origin: 'verified'` would manufacture exactly the
  proof this change makes unforgeable, and would reintroduce self-asserted
  authority through the back door.

The other candidate — derive authority by probing every dcrouter-hosted
`DomainDoc` at upgrade time — was rejected because its outcome would depend on
whether a public DoH resolver happened to be reachable at that moment, so the
same upgrade would claim different zones on different runs, and a migration does
not re-run to correct itself. Nondeterministic authority is worse than no
automation.

The upgrade path is instead **stop, back up, seed, start, verify** (above), backed
by three independent loud signals if seeding is skipped:
`DnsAuthorityManager.start()` logs at `error` on an empty set,
`DnsServerRuntime.setup()` logs at `error`, and the startup drift audit enumerates
precisely the zones to claim. Every one of them is remediable through
`dns-authority:write` without a restart, which makes a missed seed recoverable
without weakening the proof model.

### SmartDNS 8.2 integration

The upstream authority and lifecycle APIs are consumed directly:

1. **The private-route overlay must declare itself non-authoritative.** It
   registers `*` for `A` (`registerPrivateRouteHandler`). The hardened smartdns
   authority model *suppresses* a default-`authoritative` handler for any name
   outside every configured zone, so overlay hostnames proven by `provider-zone`
   — owned, but not delegation-verified, therefore not in `authoritativeZones` —
   would silently stop being answered. It registers with
   `{ authority: 'non-authoritative', owner: 'private-route-overlay' }`.

2. **A supported way to re-zone a running server.** `DnsServerRuntime` calls
   `setAuthoritativeZones()` and never depends on constructor-array identity.
   Setter failure leaves the prior local snapshot intact so authority rollback
   can reconcile against the last successfully applied state.

3. **Per-registration teardown.** `DnsManager` retains the handle returned by
   every `registerHandler()` call. RRset refresh, authority revocation, domain
   deletion, shutdown, and server replacement invoke those handles, so another
   owner's identical pattern/type registration survives.

### Generated apex NS has exactly one owner

`DnsServerRuntime` used to emit a static apex NS set per `dnsScopes` entry, and
`DnsManager` skipped those zones to avoid duplicates. Under database-sourced
authority that is wrong in both directions — a zone verified after startup would
never get them, and a zone whose authority was revoked would keep them until an
unrelated restart. `DnsManager.registerAuthoritativeZoneDefaults()` is now the
only source, for every authoritative zone, reconciled in-process both ways.

Consequence worth knowing: generated apex NS hangs off a dcrouter-hosted
`DomainDoc`. A verified zone without one still claims authority and returns its
configured SOA plus authoritative NODATA/NXDOMAIN, but serves no persisted records
or generated apex NS. That lame, effectively empty zone is reported as
`verified-but-unhosted` drift.

## Domain ownership gates certificates and authoritative DNS (2026-07-25)

`ts/dns/domain-ownership.ts` is the single predicate. Ownership is proven by
exactly two things, neither forgeable through the ops API:

- `provider-zone` — a `DomainDoc` with `source === 'provider'` and a `providerId`.
  It can only get there via `importDomainsFromProvider()`, which requires the zone
  to be listed by a credentialed provider account.
- `delegation-verified-zone` — the FQDN is covered by a zone in the DNS authority
  set (exact or a subzone). A zone only enters that set by having its public NS
  records observed naming our nameservers, which an ops-API caller cannot
  arrange. This replaced `configured-dns-scope`, which read `options.dnsScopes`:
  deployment configuration was a real trust boundary, but the price was a second,
  un-reconcilable representation of DNS authority.

Everything else — including a `source === 'dcrouter'` `DomainDoc` outside the
authority set — is operator self-assertion and fails closed.

**`DomainDoc.authoritative` has been recording intent, not fact.** In production
all four of `gated.one`, `hard.global`, `shoppinglist.app` and `social.io` carry
`authoritative: true` with `providerId: no` — a flag nobody ever proved, written
unconditionally by the old `createDcrouterDomain`. Do not read it as evidence of
anything. The predicate ignores it and derives authority from proof, and
`DnsManager.syncAuthoritativeFlags()` now rewrites the stored flag to match the
current verdict whenever the authority set changes, so the column converges on
the truth instead of preserving a stale claim.

**Known gap — no persisted lifecycle state.** `DomainDoc.source` is only
`dcrouter | provider` and `updateDomain` can change only `description`, so
"we intend to manage this later" still cannot be represented distinctly from
"we serve this authoritatively now"; the invariants above are enforced by
recomputing ownership at every decision point instead. The recommended target is
a four-state machine — `pending`/`unverified`, `active-provider`,
`active-dcrouter`, `suspended`/`revoked` — with a generation-fenced
`verifyDomain`/`activateDomain` that proves delegation against the configured
public nameserver identities, `createDomain` entering `pending`, and
`deactivateDomain` tearing down runtime state. Two properties to preserve when it
lands: in `active-provider`, `authoritative` stays **false** and loss of provider
proof must **suspend** writes rather than fall back to local authority; and
synthetic NS/SOA is installed only after activation, never on intent. That work
needs a persisted status field, a release-version-matched `ts_migrations` step,
new ops API methods, and UI — it is a feature program, not a defect fix.

Why this matters: SmartDNS now derives authority solely from
`authoritativeZones`; default-authority handler matches outside that set are
suppressed. dcrouter still gates handler registration on the same ownership
proof so a future authority change cannot activate stale or self-asserted data,
and the non-authoritative private overlay remains explicitly attributable.

Enforcement points:

- `RouteConfigManager.createRoute` / `updateRoute` — refuse an enabled route whose
  `action.tls.certificate === 'auto'` covers an unverified hostname. Disabling is
  never gated. Already-stored routes are **surfaced** as
  `unverified-domain-ownership` route warnings plus an `error` log, not refused,
  because refusing at startup would take down routes that are serving fine.
- `DnsManager.registerAuthoritativeZoneDefaults` — refuse the generated apex NS
  handler for an unverified zone; `createDcrouterDomain` / `migrateToDcrouter`
  derive `authoritative` from the verdict instead of hard-coding `true`.
- `DnsServerRuntime.syncPrivateRouteOverrides` — the private-route A overlay is
  "internal" by intent only, never by mechanism. Ungated, one route was enough to
  make dcrouter publicly hand out an RFC1918 address, authoritatively, for a
  third party's domain.
- `certProvisionFunction` and `reprovisionCertificateDomain` — refuse before
  starting an ACME order that cannot succeed.

## DoH path matching happens after TLS termination (2026-08-23)

SmartProxy cannot match an HTTP path while the incoming stream is still TLS
ciphertext. dcrouter therefore persists one nameserver-domain/port socket route
with `tls.mode: 'terminate'` and no `match.path`, then hands the cleartext stream
to SmartDNS `handleHttpSocket()`. SmartDNS performs the RFC 8484 dispatch for
both canonical `/dns-query` and the already-shipped `/resolve` alias. Keeping two
path-scoped socket routes would make the route decision before either path exists.

## DNS source transitions commit before runtime state (2026-08-23)

A provider-to-dcrouter migration is one SmartData session transaction: re-read
the `DomainDoc` and all `DnsRecordDoc` rows in the session, convert every record,
remove provider-only metadata, then convert the domain. Any record or domain save
failure aborts the whole transition. Runtime RRset/default handlers are rebuilt
only after commit, so a failed transaction cannot report success or expose a
partially local zone.

## ACME retry budgets are three independent mechanisms (2026-07-25)

Do not assume they share semantics, caps, or re-arm behaviour:

| | `SmartAcmeLifecycle` | `CertProvisionScheduler` | SmartProxy `certFailureAt` |
|---|---|---|---|
| scope | provider startup | per domain | per domain |
| increments on | failed `smartAcme.start()` | `certProvisionFunction` throw | `certProvisionFunction` throw |
| backoff | 5 s→1 h, ±20% jitter | `min(failures², 24 h)` | flat 30 min |
| cap | 20 attempts, then gives up | **none** — grows forever | n/a |
| persisted | no | yes (`CertBackoffDoc`) | no (in-memory, private) |
| re-armed by | `startInBackground()` only: a SmartProxy rebuild, `rearm()`, or a process restart. **Never on a timer.** | time | time, or a successful provision it cannot reach |

`classifyAcmeFailure()` (`ts/acme/acme-failure-classification.ts`) decides which
causes may enter a budget at all. A configuration cause (no managed domain, no
provider zone, unverified ownership, bad ACME account, CAA) is terminal: it is
raised as `AcmePermanentFailureError` / `DomainOwnershipError` and never consumes
a budget. Unrecognised causes stay **transient** on purpose — guessing "permanent"
would strand domains a retry would have fixed.

## smartmta Migration (2026-02-11)

### Overview
dcrouter's custom MTA code (~27,149 lines / 68 files in `ts/mail/` + `ts/deliverability/`) has been replaced with `@push.rocks/smartmta` v5.2.1, a TypeScript+Rust hybrid MTA. dcrouter is now an orchestrator that wires together SmartProxy, smartmta, smartdns, smartradius, and OpsServer.

### Architecture
- **No socket-handler mode** — smartmta's Rust SMTP server binds its own ports directly
- **SmartProxy forward mode only** — external email ports forwarded to internal ports where smartmta listens
- Email traffic flow: External Port → SmartProxy → Internal Port → smartmta UnifiedEmailServer

### Key API Differences (smartmta vs old custom MTA)
- `updateEmailRoutes()` instead of `updateRoutes()`
- `dkimCreator` is public (no need for `(this.emailServer as any).dkimCreator`)
- `bounceManager` is private, but exposed via public methods:
  - `emailServer.getSuppressionList()`
  - `emailServer.getHardBouncedAddresses()`
  - `emailServer.getBounceHistory(email)`
  - `emailServer.removeFromSuppressionList(email)`
- `Email` class imported from `@push.rocks/smartmta`
- `IAttachment` type accessed via `Core` namespace: `import { type Core } from '@push.rocks/smartmta'; type IAttachment = Core.IAttachment;`

### Deleted Directories
- `ts/mail/` (60 files) — replaced by smartmta
- `ts/deliverability/` (3 files) — IPWarmupManager/SenderReputationMonitor will move to smartmta
- `ts/errors/email.errors.ts`, `ts/errors/mta.errors.ts` — smartmta has its own errors
- `ts/cache/documents/classes.cached.bounce.ts`, `classes.cached.suppression.ts`, `classes.cached.dkim.ts` — smartmta handles its own persistence

### Remaining Cache Documents
- `CachedEmail` — kept (dcrouter-level queue persistence)
- `CachedIPReputation` — kept (dcrouter-level IP reputation caching)

### Dependencies Removed
mailauth, mailparser, @types/mailparser, ip, @push.rocks/smartmail, @push.rocks/smartrule, node-forge

### Pre-existing Test Failures (not caused by migration)
- `test/test.jwt-auth.ts` — `response.text is not a function` (webrequest compatibility issue)
- `test/test.opsserver-api.ts` — same webrequest issue, timeouts

### smartmta Location
Source at `../../push.rocks/smartmta`, release with `gitzone commit -ypbrt`

## Dependency Upgrade (2026-02-11)

### SmartProxy v23.1.2 Route Validation
- SmartProxy 23.1.2 enforces stricter route validation
- Forward actions MUST use `targets` (array) instead of `target` (singular)
- Test configurations that call `DcRouter.start()` need `cacheConfig: { enabled: false }` to avoid starting a real MongoDB process in tests

```typescript
// WRONG - will fail validation
action: { type: 'forward', target: { host: 'localhost', port: 10025 } }

// CORRECT
action: { type: 'forward', targets: [{ host: 'localhost', port: 10025 }] }
```

**Files Fixed:**
- `ts/classes.dcrouter.ts` - `generateEmailRoutes()` method
- `test/test.dcrouter.email.ts` - Updated assertions and added `cacheConfig: { enabled: false }`

## Dependency Upgrade (2026-02-10)

### SmartProxy v23.1.0 Upgrade
- `@push.rocks/smartproxy`: 22.4.2 → 23.1.0

**Key Changes:**
- Rust-based proxy components for improved performance
- Rust binary runs as separate process via IPC
- `getStatistics()` now returns `Promise<any>` (was synchronous)
- nftables-proxy removed (not used by dcrouter)

**Code Changes Required:**
```typescript
// Old (synchronous)
const proxyStats = this.dcRouter.smartProxy.getStatistics();

// New (async)
const proxyStats = await this.dcRouter.smartProxy.getStatistics();
```

**Files Modified:**
- `ts/monitoring/classes.metricsmanager.ts` - Added `await` to `getStatistics()` call

## Dependency Upgrade (2026-02-01)

### Major Upgrades Completed
- `@api.global/typedserver`: 3.0.80 → 8.3.0
- `@api.global/typedsocket`: 3.1.1 → 4.1.0
- `@apiclient.xyz/cloudflare`: 6.4.3 → 7.1.0
- `@design.estate/dees-catalog`: 1.12.4 → 3.41.4
- `@push.rocks/smartpath`: 5.1.0 → 6.0.0
- `@push.rocks/smartproxy`: 19.6.17 → 22.4.2
- `@push.rocks/smartrequest`: 2.1.0 → 5.0.1
- `uuid`: 11.1.0 → 13.0.0

### Breaking Changes Fixed

1. **SmartProxy v22**: `target` → `targets` (array)
   ```typescript
   // Old
   action: { type: 'forward', target: { host: 'x', port: 25 } }
   // New
   action: { type: 'forward', targets: [{ host: 'x', port: 25 }] }
   ```

2. **SmartRequest v5**: `SmartRequestClient` → `SmartRequest`, `.body` → `.json()`
   ```typescript
   // Old
   const resp = await plugins.smartrequest.SmartRequestClient.create()...post();
   const json = resp.body;
   // New
   const resp = await plugins.smartrequest.SmartRequest.create()...post();
   const json = await resp.json();
   ```

3. **dees-catalog v3**: Icon naming changed to library-prefixed format
   ```typescript
   // Old (deprecated but supported)
   <dees-icon iconFA="check"></dees-icon>
   // New
   <dees-icon icon="fa:check"></dees-icon>
   <dees-icon icon="lucide:menu"></dees-icon>
   ```

### TC39 Decorators
- ts_web components updated to use `accessor` keyword for `@state()` decorators
- Required for TC39 standard decorator support

### tswatch Configuration
The project now uses tswatch for development:
```bash
pnpm run watch
```
Configuration in `.smartconfig.json`:
```json
{
  "@git.zone/tswatch": {
    "watchers": [{
      "name": "dcrouter-dev",
      "watch": ["ts/**/*.ts", "ts_*/**/*.ts", "test_watch/devserver.ts"],
      "command": "pnpm run build && tsrun test_watch/devserver.ts",
      "restart": true,
      "debounce": 500,
      "runOnStart": true
    }]
  }
}
```

## RADIUS Server Integration (2026-02-01)

### Overview
DcRouter now supports RADIUS server functionality for network authentication via `@push.rocks/smartradius`.

### Key Features
- **MAC Authentication Bypass (MAB)** - Authenticate network devices based on MAC address
- **VLAN Assignment** - Assign VLANs based on MAC address or OUI patterns
- **RADIUS Accounting** - Track sessions, data usage, and billing

### Configuration Example
```typescript
const dcRouter = new DcRouter({
  radiusConfig: {
    authPort: 1812,      // Authentication port (default)
    acctPort: 1813,      // Accounting port (default)
    clients: [
      {
        name: 'switch-1',
        ipRange: '192.168.1.0/24',
        secret: 'shared-secret',
        enabled: true
      }
    ],
    vlanAssignment: {
      defaultVlan: 100,   // VLAN for unknown MACs
      allowUnknownMacs: true,
      mappings: [
        { mac: '00:11:22:33:44:55', vlan: 10, enabled: true },
        { mac: '00:11:22', vlan: 20, enabled: true }  // OUI pattern
      ]
    },
    accounting: {
      enabled: true,
      retentionDays: 30
    }
  }
});
```

### Components
- `RadiusServer` - Main server wrapping smartradius
- `VlanManager` - MAC-to-VLAN mapping with OUI pattern support
- `AccountingManager` - Session tracking and billing data

### OpsServer API Endpoints
- `getRadiusClients` / `setRadiusClient` / `removeRadiusClient` - Client management
- `getVlanMappings` / `setVlanMapping` / `removeVlanMapping` - VLAN mappings
- `testVlanAssignment` - Test what VLAN a MAC would get
- `getRadiusSessions` / `disconnectRadiusSession` - Session management
- `getRadiusStatistics` / `getRadiusAccountingSummary` - Statistics

### Files
- `ts/radius/` - RADIUS module
- `ts/opsserver/handlers/radius.handler.ts` - OpsServer handler
- `ts_interfaces/requests/radius.ts` - TypedRequest interfaces

## Test Fix: test.dcrouter.email.ts (2026-02-01)

### Issue
The test `DcRouter class - Custom email storage path` was failing with "domainConfigs is not iterable".

### Root Cause
The test was using outdated email config properties:
- Used `domainRules: []` (non-existent property)
- Used `defaultMode` (non-existent property)
- Missing required `domains: []` property
- Missing required `routes: []` property
- Referenced `router.unifiedEmailServer` instead of `router.emailServer`

### Fix
Updated the test to use the correct `IUnifiedEmailServerOptions` interface properties:
```typescript
const emailConfig: IEmailConfig = {
  ports: [2525],
  hostname: 'mail.example.com',
  domains: [], // Required: domain configurations
  routes: []   // Required: email routing rules
};
```

And fixed the property name:
```typescript
expect(router.emailServer).toBeTruthy();  // Not unifiedEmailServer
```

### Key Learning
When using `IUnifiedEmailServerOptions` (aliased as `IEmailConfig` in some tests):
- `domains: IEmailDomainConfig[]` is required (array of domain configs)
- `routes: IEmailRoute[]` is required (email routing rules)
- Access the email server via `dcRouter.emailServer` not `dcRouter.unifiedEmailServer`

## Network Metrics Implementation (2025-06-23)

### SmartProxy Metrics API Integration
- Updated to use new SmartProxy metrics API (v19.6.7)
- Use `getMetrics()` for detailed metrics with grouped methods:
  ```typescript
  const metrics = smartProxy.getMetrics();
  metrics.connections.active()      // Current active connections
  metrics.throughput.instant()      // Real-time throughput {in, out}
  metrics.connections.topIPs(10)    // Top 10 IPs by connection count
  ```
- Use `getStatistics()` for basic stats

### Network Traffic Display
- All throughput values shown in bits per second (kbit/s, Mbit/s, Gbit/s)
- Conversion: `bytesPerSecond * 8 / 1000000` for Mbps
- Network graph shows separate lines for inbound (green) and outbound (purple)
- Throughput tiles and graph use same data source for consistency

### Requests/sec vs Connections
- Requests/sec shows HTTP request counts (derived from connections)
- Single connection can handle multiple requests
- Current implementation tracks connections, not individual requests
- Trend line shows historical request counts, not throughput

## DKIM Implementation Status (2025-05-30)

**Note:** DKIM is now handled by `@push.rocks/smartmta`. The `dkimCreator` is a public property on `UnifiedEmailServer`.

## SmartProxy Usage

### New Route-Based Architecture (v18+)
- SmartProxy now uses a route-based configuration system
- Routes define match criteria and actions instead of simple port-to-port forwarding
- All traffic types (HTTP, HTTPS, TCP, WebSocket) are configured through routes

```typescript
// NEW: Route-based SmartProxy configuration
const smartProxy = new plugins.smartproxy.SmartProxy({
  routes: [
    {
      name: 'https-traffic',
      match: {
        ports: 443,
        domains: ['example.com', '*.example.com']
      },
      action: {
        type: 'forward',
        target: {
          host: 'backend.server.com',
          port: 8080
        }
      },
      tls: {
        mode: 'terminate',
        certificate: 'auto'
      }
    }
  ],
  defaults: {
    target: {
      host: 'fallback.server.com',
      port: 8080
    }
  },
  acme: {
    accountEmail: 'admin@example.com',
    enabled: true,
    useProduction: true
  }
});
```

### Migration from Old to New
```typescript
// OLD configuration style (deprecated)
{
  fromPort: 443,
  toPort: 8080,
  targetIP: 'backend.server.com',
  domainConfigs: [...]
}

// NEW route-based style
{
  routes: [{
    name: 'main-route',
    match: { ports: 443 },
    action: {
      type: 'forward',
      target: { host: 'backend.server.com', port: 8080 }
    }
  }]
}
```

### Direct Component Usage
- Use SmartProxy components directly instead of creating your own wrappers
- SmartProxy already includes Port80Handler and NetworkProxy functionality
- When using SmartProxy, configure it directly rather than instantiating Port80Handler or NetworkProxy separately

### Certificate Management
- SmartProxy has built-in ACME certificate management
- Configure it in the `acme` property of SmartProxy options
- Use `accountEmail` (not `email`) for the ACME contact email
- SmartProxy handles both HTTP-01 challenges and certificate application automatically

## qenv Usage

### Direct Usage
- Use qenv directly instead of creating environment variable wrappers
- Instantiate qenv with appropriate basePath and nogitPath:

```typescript
const qenv = new plugins.qenv.Qenv('./', '.nogit/');
const value = await qenv.getEnvVarOnDemand('ENV_VAR_NAME');
```

## TypeScript Interfaces

### SmartProxy Interfaces
- Always check the interfaces from the node_modules to ensure correct property names
- Important interfaces for the new architecture:
  - `ISmartProxyOptions`: Main configuration with `routes` array
  - `IRouteConfig`: Individual route configuration
  - `IRouteMatch`: Match criteria for routes
  - `IRouteTarget`: Target configuration for forwarding
  - `IAcmeOptions`: ACME certificate configuration
  - `TTlsMode`: TLS handling modes ('passthrough' | 'terminate' | 'terminate-and-reencrypt')

### New Route Configuration
```typescript
interface IRouteConfig {
  name: string;
  match: {
    ports: number | number[];
    domains?: string | string[];
    path?: string;
    headers?: Record<string, string | RegExp>;
  };
  action: {
    type: 'forward' | 'redirect' | 'block' | 'static';
    target?: {
      host: string | string[] | ((context) => string);
      port: number | 'preserve' | ((context) => number);
    };
  };
  tls?: {
    mode: TTlsMode;
    certificate?: 'auto' | { key: string; cert: string; };
  };
  security?: {
    authentication?: IRouteAuthentication;
    rateLimit?: IRouteRateLimit;
    ipAllowList?: string[];
    ipBlockList?: string[];
  };
}
```

### Required Properties
- For `ISmartProxyOptions`, `routes` array is the main configuration
- For `IAcmeOptions`, use `accountEmail` for the contact email
- Routes must have `name`, `match`, and `action` properties

## Testing

### Test Structure
- Follow the project's test structure, using `@push.rocks/tapbundle`
- Use `expect(value).toEqual(expected)` for equality checks
- Use `expect(value).toBeTruthy()` for boolean assertions

```typescript
tap.test('test description', async () => {
  const result = someFunction();
  expect(result.property).toEqual('expected value');
  expect(result.valid).toBeTruthy();
});
```

### Cleanup
- Include a cleanup test to ensure proper test resource handling
- Add a `stop` test to forcefully end the test when needed:

```typescript
tap.test('stop', async () => {
  await tap.stopForcefully();
});
```

## Architecture Principles

### Simplicity
- Prefer direct usage of libraries instead of creating wrappers
- Don't reinvent functionality that already exists in dependencies
- Keep interfaces clean and focused, avoiding unnecessary abstraction layers

### Component Integration
- Leverage built-in integrations between components (like SmartProxy's ACME handling)
- Use parallel operations for performance (like in the `stop()` method)
- Separate concerns clearly (HTTP handling vs. SMTP handling)

## Email Integration with SmartProxy

### Architecture (Post-Migration)
- Email traffic is routed through SmartProxy using automatic route generation
- smartmta's UnifiedEmailServer runs on internal ports and receives forwarded traffic from SmartProxy
- SmartProxy handles external ports (25, 587, 465) and forwards to internal ports
- smartmta's Rust SMTP bridge handles SMTP protocol processing

### Port Mapping
- External port 25 → Internal port 10025 (SMTP)
- External port 587 → Internal port 10587 (Submission)
- External port 465 → Internal port 10465 (SMTPS)

### TLS Handling
- Ports 25 and 587: Use 'passthrough' mode (STARTTLS handled by smartmta)
- Port 465: Use 'terminate' mode (SmartProxy handles TLS termination)

## SmartMetrics Integration (2025-06-12) - COMPLETED

### Overview
Fixed the UI metrics display to show accurate CPU and memory data from SmartMetrics.

### Key Findings
1. **CPU Metrics:**
   - SmartMetrics provides `cpuUsageText` as a string percentage
   - MetricsManager parses it as `cpuUsage.user` (system is always 0)
   - UI was incorrectly dividing by 2, showing half the actual CPU usage

2. **Memory Metrics:**
   - SmartMetrics calculates `maxMemoryMB` as minimum of:
     - V8 heap size limit
     - System total memory
     - Docker memory limit (if available)
   - Provides `memoryUsageBytes` (total process memory including children)
   - Provides `memoryPercentage` (pre-calculated percentage)
   - UI was only showing heap usage, missing actual memory constraints

### Changes Made
1. **MetricsManager Enhanced:**
   - Added `maxMemoryMB` from SmartMetrics instance
   - Added `actualUsageBytes` from SmartMetrics data
   - Added `actualUsagePercentage` from SmartMetrics data
   - Kept existing memory fields for compatibility

2. **Interface Updated:**
   - Added optional fields to `IServerStats.memoryUsage`
   - Fields are optional to maintain backward compatibility

3. **UI Fixed:**
   - Removed incorrect CPU division by 2
   - Uses `actualUsagePercentage` when available (falls back to heap percentage)
   - Shows actual memory usage vs max memory limit (not just heap)

### Result
- CPU now shows accurate usage percentage
- Memory shows percentage of actual constraints (Docker/system/V8 limits)
- Better monitoring for containerized environments

## Network UI Implementation (2025-06-20) - COMPLETED

### Overview
Revamped the Network UI to display real network data from SmartProxy instead of mock data.

### Architecture
1. **MetricsManager Integration:**
   - Already integrates with SmartProxy via `dcRouter.smartProxy.getStats()`
   - Extended with `getNetworkStats()` method to expose unused metrics:
     - `getConnectionsByIP()` - Connection counts by IP address
     - `getThroughputRate()` - Real-time bandwidth rates (bytes/second)
     - `getTopIPs()` - Top connecting IPs sorted by connection count
   - Note: SmartProxy base interface doesn't include all methods, manual implementation required

2. **Existing Infrastructure Leveraged:**
   - `getActiveConnections` endpoint already exists in security.handler.ts
   - Enhanced to include real SmartProxy data via MetricsManager
   - IConnectionInfo interface already supports network data structures

3. **State Management:**
   - Added `INetworkState` interface following existing patterns
   - Created `networkStatePart` with connections, throughput, and IP data
   - Integrated with existing auto-refresh mechanism

4. **UI Changes (Minimal):**
   - Removed `generateMockData()` method and all mock generation
   - Connected to real `networkStatePart` state
   - Added `renderTopIPs()` section to display top connected IPs
   - Updated traffic chart to show real request data
   - Kept all existing UI components (DeesTable, DeesChartArea)

### Implementation Details
1. **Data Transformation:**
   - Converts IConnectionInfo[] to INetworkRequest[] for table display
   - Calculates traffic buckets based on selected time range
   - Maps connection data to chart-compatible format

2. **Real Metrics Displayed:**
   - Active connections count (from server stats)
   - Requests per second (calculated from recent connections)
   - Throughput rates (currently showing 0 until SmartProxy exposes rates)
   - Top IPs with connection counts and percentages

3. **TypeScript Fixes:**
   - SmartProxy methods like `getThroughputRate()` not in base interface
   - Implemented manual fallbacks for missing methods
   - Fixed `publicIpv4` → `publicIp` property name

### Result
- Network view now shows real connection activity
- Auto-refreshes with other stats every second
- Displays actual IPs and connection counts
- No more mock/demo data
- Minimal code changes (streamlined approach)

### Throughput Data Fix (2025-06-20)
The throughput was showing 0 because:
1. MetricsManager was hardcoding throughputRate to 0, assuming the method didn't exist
2. SmartProxy's `getStats()` returns `IProxyStats` interface, but the actual object (`MetricsCollector`) implements `IProxyStatsExtended`
3. `getThroughputRate()` only exists in the extended interface

**Solution implemented:**
1. Updated MetricsManager to check if methods exist at runtime and call them
2. Added property name mapping (`bytesInPerSec` → `bytesInPerSecond`)
3. Created new `getNetworkStats` endpoint in security.handler.ts
4. Updated frontend to call the new endpoint for complete network metrics

The throughput data now flows correctly from SmartProxy → MetricsManager → API → UI.

## Email Operations Dashboard (2026-02-01)

### Overview
Replaced mock data in the email UI with real backend data from the delivery queue and security logger.

### New Files Created
- `ts_interfaces/requests/email-ops.ts` - TypedRequest interfaces for email operations
- `ts/opsserver/handlers/email-ops.handler.ts` - Backend handler for email operations

### Key Interfaces
- `IReq_GetQueuedEmails` - Fetch emails from delivery queue by status
- `IReq_GetSentEmails` - Fetch delivered emails
- `IReq_GetFailedEmails` - Fetch failed emails
- `IReq_ResendEmail` - Re-queue a failed email for retry
- `IReq_GetSecurityIncidents` - Fetch security events from SecurityLogger
- `IReq_GetBounceRecords` - Fetch bounce records and suppression list
- `IReq_RemoveFromSuppressionList` - Remove email from suppression list

### UI Changes (ops-view-emails.ts)
- Replaced mock folders (inbox/sent/draft/trash) with operations views:
  - **Queued**: Emails pending delivery
  - **Sent**: Successfully delivered emails
  - **Failed**: Failed emails with resend capability
  - **Security**: Security incidents from SecurityLogger
- Removed `generateMockEmails()` method
- Added state management via `emailOpsStatePart` in appstate.ts
- Added resend button for failed emails
- Added security incident detail view

### Data Flow
```
UnifiedDeliveryQueue → EmailOpsHandler → TypedRequest → Frontend State → UI
SecurityLogger → EmailOpsHandler → TypedRequest → Frontend State → UI
BounceManager → EmailOpsHandler → TypedRequest → Frontend State → UI
```

### Backend Data Access
The handler accesses data from:
- `dcRouter.emailServer.deliveryQueue` - Email queue items (IQueueItem)
- `SecurityLogger.getInstance()` - Security events (ISecurityEvent)
- `emailServer.bounceManager` - Bounce records and suppression list

## OpsServer UI Fixes (2026-02-02)

### Configuration Page Fix
The configuration page had field name mismatches between frontend and backend:
- Frontend expected `server` and `storage` sections
- Backend returns `proxy` section (not `server`)
- Backend has no `storage` section

**Fix**: Updated `ops-view-config.ts` to use correct section names:
- `proxy` instead of `server`
- Removed non-existent `storage` section
- Added optional chaining (`?.`) for safety

### Auth Persistence Fix
Login state was using `'soft'` mode in Smartstate which is memory-only:
- User login was lost on page refresh
- State reset to logged out after browser restart

**Changes**:
1. `ts_web/appstate.ts`: Changed loginStatePart from `'soft'` to `'persistent'`
   - Now uses IndexedDB to persist across browser sessions
2. `ts/opsserver/handlers/admin.handler.ts`: JWT expiry changed from 7 days to 24 hours
3. `ts_web/elements/ops-dashboard.ts`: Added JWT expiry check on session restore
   - Validates stored JWT hasn't expired before auto-logging in
   - Clears expired sessions and shows login form

## Config UI Read-Only Conversion (2026-02-03)

### Overview
The configuration UI has been converted from an editable interface to a read-only display. DcRouter is configured through code or remotely, not through the UI.

### Changes Made

1. **Backend (`ts/opsserver/handlers/config.handler.ts`)**:
   - Removed `updateConfiguration` handler
   - Removed `updateConfiguration()` private method
   - Kept `getConfiguration` handler (read-only)

2. **Interfaces (`ts_interfaces/requests/config.ts`)**:
   - Removed `IReq_UpdateConfiguration` interface
   - Kept `IReq_GetConfiguration` interface

3. **Frontend (`ts_web/elements/ops-view-config.ts`)**:
   - Removed `editingSection` and `editedConfig` state properties
   - Removed `startEdit()`, `cancelEdit()`, `saveConfig()` methods
   - Removed Edit/Save/Cancel buttons
   - Removed warning banner about immediate changes
   - Enhanced read-only display with:
     - Status badges for boolean values (enabled/disabled)
     - Array display as pills/tags with counts
     - Section icons (mail, globe, network, shield)
     - Better formatting for numbers and byte sizes
     - Empty state handling ("Not configured", "None configured")
     - Info note explaining configuration is read-only

4. **State Management (`ts_web/appstate.ts`)**:
   - Removed `updateConfigurationAction`
   - Kept `fetchConfigurationAction` (read-only)

5. **Tests (`test/test.protected-endpoint.ts`)**:
   - Replaced `updateConfiguration` tests with `verifyIdentity` tests
   - Added test for read-only config access
   - Kept auth flow testing with different protected endpoint

6. **Documentation**:
   - `readme.md`: Updated API endpoints to show config as read-only
   - `ts_web/readme.md`: Removed `updateConfigurationAction` from actions list
   - `ts_interfaces/readme.md`: Removed `IReq_UpdateConfiguration` from table

### Visual Display Features
- Boolean values shown as colored badges (green=enabled, red=disabled)
- Arrays displayed as pills with count summaries
- Section headers with relevant Lucide icons
- Numbers formatted with locale separators
- Byte sizes auto-formatted (B, KB, MB, GB)
- Time values shown with "seconds" suffix
- Nested objects with visual indentation

## Smartdata Cache System (2026-02-03)

### Overview
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `~/.serve.zone/dcrouter/tsmdb`.

### Technology Stack
| Layer | Package | Purpose |
|-------|---------|---------|
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |

### TC39 Decorators
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
- Removed `experimentalDecorators: true`
- Removed `emitDecoratorMetadata: true`

This is required for smartdata v7+ compatibility.

### Cache Document Classes
Located in `ts/cache/documents/`:

| Class | Purpose | Default TTL |
|-------|---------|-------------|
| `CachedEmail` | Email queue items | 30 days |
| `CachedIPReputation` | IP reputation lookups | 24 hours |

Note: CachedBounce, CachedSuppression, and CachedDKIMKey were removed in the smartmta migration (smartmta handles its own persistence for those).

### Usage Pattern
```typescript
// Document classes use smartdata decorators
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
  @plugins.smartdata.svDb()
  public createdAt: Date = new Date();

  @plugins.smartdata.svDb()
  public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);

  @plugins.smartdata.unI()
  @plugins.smartdata.svDb()
  public id: string;
  // ...
}

// Query examples
const email = await CachedEmail.getInstance({ id: 'abc123' });
const pending = await CachedEmail.getInstances({ status: 'pending' });
await email.save();
await email.delete();
```

### Configuration
```typescript
const dcRouter = new DcRouter({
  cacheConfig: {
    enabled: true,
    storagePath: '~/.serve.zone/dcrouter/tsmdb',
    dbName: 'dcrouter',
    cleanupIntervalHours: 1,
    ttlConfig: {
      emails: 30,       // days
      ipReputation: 1,  // days
      bounces: 30,      // days
      dkimKeys: 90,     // days
      suppression: 30   // days
    }
  }
});
```

### Cache Cleaner
- Runs hourly by default (configurable via `cleanupIntervalHours`)
- Finds and deletes documents where `expiresAt < now()`
- Uses smartdata's `getInstances()` + `delete()` pattern

### Key Files
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
- `ts/cache/documents/*.ts` - Document class definitions
