The domains list had no way to narrow itself down, so an operator with
dozens of zones had to scan the whole table to find the drifted ones.
Add two client-side filters above the table (the full list already
arrives in one request, so server-side filtering would only add contract
surface). The status filter compares against the same derived value the
row's badge renders — a domain with no template shows "без шаблона" and
must not match "drift" just because it kept a stale last_check_status
from before its template was detached, so that derivation lives in one
function used by both.
Sorting is now decided in the store: ListDomains orders by zone_name
instead of created_at, so every consumer of the list gets the same order
and the filters never reorder anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
req() in the frontend api client called res.json() on every non-204 2xx
response, which throws SyntaxError on the empty body returned by
POST .../customs (201) and similar endpoints. Read the response body once
as text and parse it only when non-empty, so any bodyless 2xx resolves
instead of rejecting. Added web/src/api/client.test.ts coverage that
exercises the real req() against a stubbed fetch for both the 201
(addCustom) and 204 (removeCustom) empty-body cases.
Also brought internal/store/queries/domains.sql's DeleteDomainsNotInZones
query back in sync with the hand-maintained internal/store/db/domains.sql.go
(RETURNING * -> explicit column list) since sqlc isn't installed in this
environment and the two files are kept aligned by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
Document the two Task 1-9 features in CLAUDE.md invariants and README
features. Also closes two review gaps: a Go test proving importResponse
marshals created/removed as [] (never null) on a no-op import, and a
frontend test-race fix in DomainsPage.test.tsx where the import button
was clicked before its disabled state cleared.
resolve (shared by Check/Apply) and Apply now wrap GetRecords/ApplyChanges
failures in service.ErrProviderUnavailable, matching ZoneRecords' existing
behavior. handleApply/handleCheck use errors.Is against it to return 502
with the real provider message (e.g. Selectel's 409 conflict body) instead
of masking every failure as a generic 500 "internal error"; non-provider
errors (decrypt/db/loader) are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
RecordDiff.Key() gives a stable normalized identifier ("TYPE name.") for
every diff kind, exposed as recordView.Key. ApplyRequest now takes
Updates/Prunes key lists instead of two booleans, so callers can apply a
subset of records. service.Apply builds the applied set with selected
prunes (Delete) added before selected updates (Add/Update) — an
invariant, not an option — since the provider rejects an Add/Update
whose name still conflicts with an existing record (e.g. a CNAME cannot
be created while an A on the same name still exists).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
handleCheck's error branch wrote last_check_status via an id-only UPDATE, so
an authenticated caller's own valid project id paired with a foreign domain
id in the URL could flip a stranger's domain to "error" even though Check
itself is project-scoped and would 404/error out first. Add project_id to
the WHERE clause (queries/domains.sql + generated db/domains.sql.go), thread
projectID through Store/TenantStore/SchedStore SetDomainStatus, and pass pid
from context at both call sites in handleCheck plus the scheduler.
Also collapse checkDomain's inline status derivation in scheduler.go into a
call to service.DeriveStatus, the same helper handleCheck already uses, so
there's a single source of truth for "drift vs in_sync" instead of two
copies that could drift apart.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Manual domain checks (Recheck button / diff page load) never wrote
domains.last_check_status - only the scheduler did, leaving a
newly-templated domain stuck at "unknown" until the next scheduled run.
Extract status derivation into internal/service (single source of truth):
StatusUnknown/InSync/Drift/Error constants and DeriveStatus(diff.Changeset).
The scheduler now aliases these constants instead of duplicating them.
handleCheck persists the derived status (or StatusError on failure) via
TenantStore.SetDomainStatus after every manual check - status/history only,
no notification, which remains the scheduler's job.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Parameterize used strings.ReplaceAll, so an external host that merely
ends with the zone name as a substring (e.g. "notreconops.ru." against
zone "reconops.ru") was falsely rewritten to "not{{domain_name}}.".
Replace only where the zone name sits on a DNS-label boundary (start/
end of string or a non-alphanumeric/hyphen character), and resolve to
a fixed point so adjacent occurrences sharing a single boundary
character are still both replaced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds internal/tmpl with Materialize (template placeholder -> zone name) and
Parameterize (zone name -> placeholder, the inverse used by the
template-from-zone snapshot). service.resolve now materializes the template
against DomainRef.ZoneName before diffing, so one template can be reused
across domains. LoadDomainFull (source query + hand-edited sqlc output, since
sqlc is not installed) now also selects zone_name to populate it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
toChangesetResponse initialises updates/prunes/readOnly so a zone matching
its template exactly (e.g. right after 'create template from zone') marshals
arrays, not null. DiffView/DomainDiffPage also normalise null defensively.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Introduce service.ErrProviderUnavailable, wrapped only around the
provider GetRecords call in ZoneRecords. handleZoneRecords and
handleTemplateFromZone now use errors.Is against it to tell a real
provider outage (502) apart from local resolution failures such as an
unknown domain (404), instead of collapsing every ZoneRecords error
into a blanket 502. Also fixes handleTemplateFromZone's GetDomain
error branch to return 404 "domain not found" instead of 500, for
consistency with handleSetDomainTemplate/handleDomainHistory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
LoadDomain requires a template, so a zone without one could never be
viewed or snapshotted. Adds a template-free path: store.LoadZone /
service.ZoneRef / DomainService.ZoneRecords read a zone's live records
straight from the provider (no diff, no template). GET
/domains/{did}/records exposes read-only viewing; POST
/domains/{did}/template-from-zone snapshots only managed record types
(NS/SOA excluded) into a new template and auto-attaches it to the domain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
POST /accounts now accepts secret as a provider-specific JSON object
instead of an opaque string, and validates credentials via
provider.Provider.Validate before persisting — invalid credentials get
a generic 400 without ever reaching Store.CreateAccount or echoing the
secret back.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Selectel Cloud DNS v2 requires a project IAM token in X-Auth-Token, not the
raw service-user secret; the previous client sent the static secret directly
and got 401. The client now parses Credentials.Secret as a Creds JSON blob
(username/password/account_id/project_name), exchanges it for a token via
the Identity API (POST /identity/v3/auth/tokens), and caches the token in
memory per-account until 5 minutes before expiry. ListZones/GetRecords/
ApplyChanges send the cached IAM token instead of the raw secret.
provider.Provider gains a Validate(ctx, Credentials) method so a bad account
can be rejected via trial login at creation time; all Provider fakes across
provider/registry/api/service test packages implement it as a no-op stub for
now (Task 2 will make api's mock configurable).
Security: the service-user password is folded into the token cache key via
SHA-256 (never stored in the clear) so a password change invalidates the
cached token; identity errors are generic and never echo the request body.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Dispatcher.Send now returns []ChannelResult{Type, Err} alongside the
aggregated error, and scheduler.checkDomain increments
NotificationsTotal per channel type/status instead of a single
unconditional IncNotification("dispatch", newStatus) placeholder that
ignored per-channel delivery outcome.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Domains imported without a template (TemplateID == nil) are a valid,
unconfigured state, not a failure — RunOnce now skips them before
calling checkDomain instead of letting LoadDomain's "no template" error
turn into StatusError and a spammy unknown->error notification.
isBlockedIP now also rejects 100.64.0.0/10 (RFC 6598 carrier-grade
NAT), which net.IP.IsPrivate() does not cover, closing an SSRF gap in
the webhook destination guard (both the pre-request check and the
per-dial check use isBlockedIP).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Task 5 Фазы 3: GET/PUT /schedule (дефолт при отсутствии строки, валидация
interval>=60), POST/GET/DELETE /channels (секрет шифруется Cipher, никогда
не возвращается в ответах), POST /channels/{cid}/test через узкий
TestSender-интерфейс (200/502 без утечки секрета), GET /domains/{did}/history
(сначала GetDomain для project-scoping, затем ListCheckRuns — иначе IDOR
через check_runs, который сам по себе не scoped по project).
Добавлены store.GetDomain (обёртка над существующим sqlc-запросом) и
store.ListCheckRuns (новый запрос + sqlc regen) для поддержки истории.
Фаза 2, Task 1: добавлена таблица sessions и nullable password_hash у
users, sqlc-запросы и *Store-обёртки (CreateUser, GetUserByEmail,
CreateProjectForUser, GetProjectOwned, GetUserProject, CreateSession,
GetSessionUser, DeleteSession, RegisterUser в транзакции), интеграционные
тесты на testcontainers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Коммитим internal/web/dist/index.html как минимальный плейсхолдер, чтобы
//go:embed all:dist находил совпадения на чистом клоне без npm/`make web`
(CRITICAL: go build ./... падал с "pattern all:dist: no matching files
found"). .gitignore теперь игнорирует только реальные build-ассеты
(internal/web/dist/* кроме index.html); `make web` перезаписывает
плейсхолдер настоящей сборкой.
Также чинит MEDIUM: голый /api (без хвостового слэша) уходил в
SPA-fallback вместо API-роутера — вынесен isAPIPath() с явной проверкой
path == "/api", покрыт TestIsAPIPath.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
Task 9 Фазы 1B: узкий интерфейс TenantStore (внутри store.Account/Template/Domain,
без db.* в api) реализован тонкими обёртками в internal/store/tenant.go; API.Store/
Cipher/Reg добавлены к существующему Svc. Роуты POST/GET/DELETE для accounts/
templates/domains + POST /accounts/{aid}/import (ListZones -> CreateDomain на зону).
accountResponse не содержит секрет ни в каком виде.