Commits
Every handler package carried its own near-identical writeError/
handleServiceError pair. Because each mapped a different subset of errors,
whatever a package forgot fell through to a 500 — most damagingly a dead
OAuth session on post, comment, and vote, which left clients with no signal
to re-authenticate and users stuck on "an internal error occurred" until
they signed out by hand.
The fix spans three layers, because the handler switch was only the visible
half: services were discarding the PDS cause before it ever reached the
boundary, and the expired-session path never produced a PDS error at all.
Changes:
- Add pds.ErrSessionExpired and IsReauthRequired. An unresumable OAuth
session fails at client construction, before any PDS request, so it had no
status to map; factory.go now tags it. Tagged narrowly, on
ErrSessionNotFound only — a store outage or cancelled request must not
read as expired, or a brief database blip would 401 every user in flight.
- Stop services swallowing the cause: 17 sites in votes, comments, and
communities now return fmt.Errorf("%w: %w", DomainSentinel, err) so the
domain sentinel and the PDS cause both stay matchable.
- Add internal/api/xrpc.Mapper, a rule table replacing all 13 switches.
Precedence is re-auth, then domain rules, then shared rules (typed
coreerrors, remaining PDS, request lifecycle), then a fallback. Re-auth
jumps the queue so a domain sentinel mapping to 403 cannot mask a 401.
- Strip pds sentinels from post delete: posts live in the community's repo,
so a rejected community credential must not tell the caller to sign in.
- Replace all 50 err == sentinel comparisons with errors.Is.
- Classify identity resolver errors in ResolveHandleToDID, so an unknown
handle is 404 and a resolver outage is 500 rather than both being 404.
- Delete the duplicate writers: handlers.WriteError, writeJSONError,
writeUpdateProfileError, and three inline switches in update_profile.go.
- Fix along the way: comments 409 on concurrent edit (was 500), community
429/413 (was 500), update_profile 400 (was 500), and routes/user.go
reporting a resolver outage as 404 ProfileNotFound.
- Add 30 tests: mapper precedence, per-package code contracts, session
classification, and handle resolution.
Error codes are a client contract and are preserved, with one deliberate
exception: a wrapped communities.ErrHandleTaken now answers NameTaken
instead of AlreadyExists. The old `err ==` never matched because the service
wraps it, so the create path always returned AlreadyExists; errors.Is now
matches and yields the code the switch plainly intended. Clients keying on
AlreadyExists for a handle collision need updating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the p1 resilience gap in the backlog
(2026-07-22-no-db-pool-limits-no-http-timeouts).
The HTTP server was constructed with only Addr and Handler, so all four
net/http timeouts were zero — which means "no deadline". A client that
opened a connection and dribbled request headers held a goroutine and a
file descriptor indefinitely; enough of them exhaust the process without a
single complete request ever arriving. Separately, sql.Open was called with
no pool configuration at all, and database/sql defaults MaxOpenConns to
unlimited, so a traffic spike could open connections until PostgreSQL's
max_connections was exhausted — locking out psql and the cmd/ maintenance
tools along with the AppView.
Both are now bounded and env-tunable. Query time is bounded server-side via
statement_timeout injected into the DSN rather than per-query context
deadlines: lib/pq does send a CancelRequest on context cancellation, but
that path needs a second connection, races the query finishing, and does
nothing if the client dies outright. Schema migrations deliberately run on
a separate connection with statement_timeout stripped, since a CREATE INDEX
killed halfway is worse than a slow one.
main() was 1090 lines with 30 inline os.Getenv calls and no config struct.
Configuration now lives in internal/config, which applies defaults, rejects
malformed values, and enforces the requirements that differ between dev and
production — reporting every problem at once so a misconfigured deployment
is fixed in one pass instead of one restart per mistake. main() is now ~240
lines and returns an error rather than calling log.Fatal, which is what
makes the deferred cleanup reachable: os.Exit skips defers, so a fatal call
partway through startup abandoned the database pool and discarded the
buffered OpenTelemetry spans describing the failure.
Changes:
- Set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout (cmd/server/httpserver.go)
- Configure the pool: MaxOpenConns, MaxIdleConns, ConnMaxLifetime, ConnMaxIdleTime (cmd/server/database.go)
- Inject statement_timeout into the app DSN; strip it for migrations (internal/config/dsn.go)
- Add internal/config with Load/Validate and per-subsystem config structs
- Split cmd/server into main, wiring, routes, consumers, database, httpserver, jobs, health, pds
- Embed goose migrations (internal/db/migrations/embed.go), removing the
working-directory dependency and the Dockerfile's migrations COPY
- Drain background work concurrently with the listener rather than after it,
so a slow in-flight request cannot consume the whole shutdown budget and
leave Jetstream cursors unflushed; drain on the listener-failure path too,
and report shutdown failures through the exit code
- Bound each background job cycle and recover per cycle rather than per
goroutine, so neither a panic nor a hang can silently kill the job
permanently; run a cycle at startup instead of waiting out the first tick
- Resolve the OAuth session store once at boot and fail loudly if absent,
rather than turning the cleanup job into a silent hourly no-op
- Add a context timeout to the instance PDS login, which used
http.DefaultClient and could hang the boot forever
- Document the new HTTP_* and DB_* variables in the env examples
Production now fails closed on OAUTH_SEAL_SECRET (base64, 32 bytes),
CURSOR_SECRET (rejects the documented CHANGE_ME placeholder), JETSTREAM_FEEDS,
APPVIEW_PUBLIC_URL and PDS_URL (must not be loopback), a non-DID INSTANCE_DID,
SKIP_DID_WEB_VERIFICATION, and a zero value for any HTTP timeout. The current
docker-compose.prod.yml and .env.prod.example satisfy all of these; a live
.env.prod with a short CURSOR_SECRET or a malformed OAUTH_SEAL_SECRET will
refuse to boot.
Verified: make test-all green across all three stages, make fmt-check clean,
go vet clean, race detector clean on the changed packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
internal/core/errors was dead code — zero importers — while six domain
packages each reimplemented the same ValidationError with a slightly
different message format. Because those were distinct Go types with
identical shapes, a handler could not ask "is this a validation failure?"
in one place: it had to call posts.IsValidationError, then
communities.IsValidationError, then aggregators.IsValidationError, and any
domain it forgot fell through to a 500.
The six packages now alias the shared type rather than redefining it:
type ValidationError = coreerrors.ValidationError
A Go type alias is the *same* type, not a similar one, so a single
errors.As at the API boundary matches validation failures from every
aliasing domain while each package keeps its own constructors and
predicates. Existing call sites compile unchanged.
Two latent bugs surfaced and are fixed:
- timeline.IsValidationError and discover.IsValidationError used a bare
type assertion rather than errors.As, so a validation error wrapped
anywhere in the stack with %w stopped being recognised and returned 500
instead of 400.
- posts.IsNotFound compared with == rather than errors.Is, which breaks
the moment any layer adds context.
Changes:
- Rewrite internal/core/errors as the canonical ValidationError,
NotFoundError, and ConflictError, with Is methods bridging the latter
two to shared sentinels
- Alias the shared types in posts, communities, aggregators,
communityFeeds, discover, and timeline
- Switch timeline/discover predicates to errors.As so they unwrap
- Switch posts.IsNotFound to errors.Is
- Replace a hand-rolled contains/anySubstring reimplementation of
strings.Contains in posts with the stdlib
- Add errors_test.go asserting the cross-domain property the whole change
exists for: reverting any alias to a local struct still compiles and
still passes each domain's own tests, so only this test catches it
Note for clients: ValidationError.Error() is now uniformly "field:
message". Previously posts returned "validation error (field): msg",
aggregators "validation error: field - msg", and timeline/discover the
bare message with no field prefix. These strings reach clients as the
XRPC error *message*; the error *code* is unchanged, so the lexicon
contract holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
make fmt-check, and therefore make lint, was already failing on main across
22 files before this branch of work. This applies `make fmt` and clears it.
Whitespace only: alignment of struct tags and trailing comments, one
over-long single-line function body split in list_test.go, and trailing
blank lines stripped at EOF. Verified by confirming every file here is
byte-identical to gofmt applied to its parent version, so the change is
semantics-preserving by construction. go build and go vet are clean and
make test-all still passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty PLCURL silently meant the production plc.directory, so a config
that merely omitted the field resolved identities against production. The
same hazard sat in identity.DefaultConfig(), which hardcoded production and
was used unmodified by ~30 integration test sites - so test runs issued real
lookups upstream for DIDs that only exist on the local PLC.
Nothing here could ever WRITE to a directory: DID registration is a PDS
operation and the dev PDS is pinned to the local PLC container, so no
production records were at risk. The silent default was still a footgun, and
an empty field should never be the way you select production.
Changes:
- NewOAuthClient errors when PLCURL is empty instead of falling through to
indigo's default directory; the directory override is now unconditional
and the startup warning became a structured log line
- identity.DefaultConfig honors PLC_DIRECTORY_URL, which the Makefile
already exports from .env.dev, pointing every test resolver at the local
PLC without touching ~30 call sites
- OAuth test helpers name their directory explicitly; unit tests use an
unroutable address so an unintended resolution fails loudly rather than
quietly reaching production
Also refreshes four tests that pinned superseded behavior:
- TestValidateActorProfile: actor.profile has had no required fields since
81ecb15, so "missing required field" is no longer a failure mode; now
asserts the constraints the schema does enforce
- TestGetCommunityFeed_BlobURLTransformation: expects embed.external#view
per the postView union, since a thumb rewritten to a URL no longer fits
the record schema
- TestOAuth_SessionFixationAttackPrevention: 62f9066 replaced raw-400
dead-ends with a first-party 302, so "not 302" asserted the mechanism
rather than the property; now asserts the redirect is first-party and
carries no session material
- TestUserCreationAndRetrieval/Resolve Handle to DID: resolves the real
bretton.dev, so it gets its own production-pinned READ-ONLY resolver
make test-all passes: 30 packages across unit, integration and E2E.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RSS bridges wrote raw non-percent-encoded feed URLs into
embed.external.sources[].uri and richtext facet#link.uri. A literal "é" in a
slug resolves fine in a browser but fails the atproto `uri` format (printable
ASCII only after the scheme), so ~12% of live posts are invalid for any
third-party tool that resolves our lexicons and validates the firehose.
Fixed at both ends: the bridges encode at the source, and the AppView
normalizes on the write path. The AppView signs community post records into
the PDS itself, so it is the only layer that can guarantee a conforming record
regardless of client. Recoverable input is repaired rather than rejected, so
pasting a link containing an accented character keeps working; only genuinely
unusable input errors.
Encoding splits the URI with plain string operations instead of net/url.
url.Parse rejects several trivially recoverable inputs (a stray "%", an
interior tab, non-ASCII userinfo, an at:// URI whose DID reads as a port), and
round-tripping through url.URL.String() decodes reserved characters — turning
%2F into a path separator and silently repointing the link at a different
resource.
Changes:
- add internal/validation/uri.go: NormalizeURI/ValidURI with a typed sentinel
per failure cause
- add uri_sanitizer.py to both bridges, wired into create_external_embed and
RichTextBuilder.add_link
- pin cross-language agreement with a shared 46-case corpus at
internal/validation/testdata/uri_vectors.json, read by both the Go and Python
suites, so a one-sided change turns the other language's tests red
- punycode hosts via UTS-46 with DNS length verification (Go) and the IDNA2008
idna package (Python); the raw Punycode profile and the stdlib IDNA2003 codec
each produced a different domain than the user linked to
- refuse javascript:/data:/vbscript:/file:/mailto: rather than repairing them
into valid records; the check runs before the already-conforming fast path,
since those URIs are pure ASCII and would otherwise pass untouched
- enforce the lexicon's maxLength of 50 on embed.external.sources, and reject a
present-but-non-array sources value instead of silently skipping it
- keep the error chain intact: ValidationError gains Err/Unwrap and
NewValidationErrorFrom, comment wrapping uses %w:%w, so errors.Is reaches the
validation sentinels from the API boundary
- stop logging rejected URIs, which can carry signed-URL tokens; log
dropped/total source counts instead, escalating to ERROR when none survive
- declare idna as a direct dependency of both bridges
Facet link URIs are handled on the write path only. SanitizeFacets (firehose
ingest) is deliberately untouched and pinned by a test: existing federated
records are not being backfilled, and dropping their link facets on reindex
would be visible content loss.
Note: a post carrying a mailto: or javascript: link facet is now rejected
outright rather than degraded to unlinked text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Never hardcode the Cloudflare token in the tracked script — source it from
scripts/.env.lexicon (gitignored) instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
goat lex lint flagged 77 string fields with no maxLength across params,
outputs, and knownValues tokens. Cap them all (cursors 500, knownValues 64,
plus per-field caps for email/password/JWTs/api-keys/search queries).
Remaining large-string warnings are intentional (base64 image fields,
long-form content at the 10:1 grapheme ratio).
Add docs/LEXICON_PUBLISHING.md (publish/hold-back sets, decisions log,
workflow) and scripts/publish-lexicon-dns.sh (idempotent Cloudflare API
upsert of the 9 _lexicon TXT delegations; moderation deferred via flag).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prepare the social.coves.* lexicons for publication (com.atproto.lexicon.schema
records + _lexicon DNS). Once published, atproto evolution rules freeze the
schemas to additive-only changes, so this pass fixes everything breaking-shaped
now: style-guide violations from the original Opus-era schemas, and every place
a schema contradicted what the Go AppView actually reads and writes. All ~11k
live records on pds.coves.me + pds.bretton.dev verified unaffected via the new
validate-live sweep (only pre-existing bridge-URI failures remain). Reviewed by
multi-model /second-opinion; its confirmed findings are folded in here.
Schema correctness:
- Record account refs pinned to DIDs (post.community, ban.community — handles
are mutable/reassignable); live posts already store DIDs
- Removed literal "$type" properties from rules.json union members; removed
invalid additionalProperties (tagCounts -> unknown); dropped record-baked
governance defaults/minimums (policy lives app-side)
- knownValues normalized to kebab-case (moderator permissions, getPosts
filters); Go normalizes legacy snake_case filters for old clients
- 1:1 byte/grapheme caps fixed to ~10:1 across records AND procedures (embeds,
post tags, community name/description, comment content, editNote; post.update
content limits were inverted)
- Over-required fields relaxed pre-freeze (profile createdBy/hostedBy,
actor.profile createdAt, rule fields, memberView.reputation);
actor.block.createdAt now required; banView.indexedAt required
- name capped at 63 bytes everywhere to match the RFC 1035 check Go enforces
Schemas now tell the truth about the wire:
- actor.profile record/views renamed bio->description(+Facets), viewer
blockUri->blocking — matching writer, firehose consumer, and getProfile;
updateProfile input keeps `bio` (the actual wire name clients send)
- community.update input is communityDid; create/update/updateProfile declare
avatarBlob/bannerBlob as base64 strings + mime types (what handlers decode),
and update only advertises fields the handler supports
- New social.coves.community.post.defs holds the shared view types (moved out
of the post.get query file); comment/feed refs re-pointed; dangling
embed.record#view refs eliminated
- New embed external#view + post#view defs; blob_transform now rewrites $type
to the #view form when it URL-ifies thumbs or resolves quoted posts, so
served payloads validate against the type they declare
- vote.create output uri/cid optional (absent on toggle-off) instead of
required-but-empty-string; handler emits omitempty; pds CreateRecord now
errors on a 200 with empty uri/cid so {} can only mean toggle-off
- moderation ban subset cleaned (banView instead of refs to the record type,
unbanUser success boolean dropped, tribunalCase as strongRef); governance
trio (ruleProposal/tribunalVote/vote) deliberately untouched and held back
from publishing until tribunal design lands
Tooling and enforcement:
- New cmd/validate-live: sweeps every social.coves.* record on a live PDS via
public XRPC and validates against local schemas; exits 2 on incomplete
sweeps, guards non-advancing cursors, sorted deterministic output
- cmd/validate-lexicon cross-ref list updated for the new defs
- update_profile handler validates displayName/bio by graphemes+bytes (uniseg)
per the published contract; fixtures and tests updated
Client coordination required before next appview deploy (tracked in memory):
transformed embeds now declare #view $types; vote toggle-off omits uri/cid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds base-uri 'self', object-src 'none', and frame-ancestors 'none' to
the coves.social CSP (frame-ancestors matches the existing site-wide
X-Frame-Options DENY).
Also documents the carve-out needed when the SvelteKit frontend joins
this stack: this static header REPLACES the app's per-request nonce'd
CSP, and its script-src 'unsafe-inline' would silently downgrade the
frontend's script-src hardening — flagged in the July 2026 frontend
security review.
Deploy note: single-file bind mount pins the inode — use
`docker compose up -d --force-recreate caddy`, not `caddy reload`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend social.coves.richtext.facet with block-level features so bridged
Lemmy/fediverse content (nested quotes, headers, code) is representable,
and add the validation/sanitization layer facets previously lacked
entirely. Reviewed via multi-model /second-opinion; all Critical and
Important findings fixed.
Lexicon (wire spec for the three renderer implementations):
- New open-union features: blockquote (optional level 1-6, nested quotes
= disjoint ranges with increasing level, never containment), heading
(required level 1-6, single line), code, codeBlock (optional language)
- Conventions in descriptions: block ranges span whole lines excluding
trailing newline; readers extend malformed ranges to line boundaries;
unknown feature $types MUST degrade to plaintext; writers strip
markdown markers and clamp nesting deeper than 6
- Caps: facets maxLength 200 (was unbounded), features-per-facet 20
- Procedure lexicons (post/comment create+update) synced with record
schemas; comment procedures declare the new InvalidFacets error
New internal/core/richtext package:
- ValidateFacets (reject) at API writes, SanitizeFacets (drop+log) at
firehose ingest -- federated records can't be bounced to their author,
and clients must never receive ranges slicing outside the content
- Structural checks: byte-range sanity, integral offsets across all four
JSON number encodings with uniform int32 bounds, features shape
- Known-feature attribute enforcement (heading.level required/range,
blockquote.level range, codeBlock.language <=40, spoiler.reason <=128);
unknown $types pass untouched to keep the union open
Write paths:
- posts.validateCreateRequest validates facets vs untrimmed content
- comments Create/Update validate vs trimmed content and REJECT facets
paired with untrimmed content -- leading whitespace would silently
shift every annotation in the signed PDS record
- New ErrInvalidFacets sentinel + HTTP 400 InvalidFacets mapping
Ingest paths (all three consumers now sanitize before indexing):
- post_consumer: sanitizedPostFacets on create + update
- comment_consumer: serializeOptionalFields sanitizes (non-mutating),
covering create/update/resurrect/re-create
- community_consumer: descriptionFacets were indexed with NO validation
(same attack class); now sanitized on create + update, stale facets
cleared when a record arrives without surviving ones, marshal
failures logged instead of silently kept
Tests (~60 new cases): full range/attribute/cap matrix incl. all JSON
number encodings, consumer wiring seams (nil content, drop+serialize,
non-mutation), trim-mismatch guard, UpdateComment rejection, indigo
schema validation of new features, executable byte-offset conventions
doc, validate-lexicon fixture. go build/vet + full unit suite clean.
Rollout note: inert until producers emit block facets. Client renderers
(coves-frontend, coves-mobile) must ship before tidepool starts
stripping markdown markers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirror the tidepool public-repo convention: internal product-planning
and AI-builder docs stay local (now gitignored); public docs keep only
the reference guides. Drop the two inbound references (Makefile help,
LOCAL_DEVELOPMENT related-docs list).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-model review follow-up to cc98f26. The AS-error branch honored any
?error= with no state validation — indigo's ProcessCallback deliberately
checks state BEFORE error, and the branch bypassed that: a forged
top-level GET to /oauth/callback?error=x&error_description=<text> rode
the victim's SameSite=Lax cookies, killed their in-flight login via
clearMobileCookies, and 302'd attacker-chosen text into the app's
trusted error UI. Separately, the server-stored redirect URI was only
fetched when the mobile cookie existed, so the cookie-less mobile user
it was meant to rescue could never benefit, and ProcessCallback
failures still dead-ended on the raw 400 page (with %v of the internal
error) that cc98f26 set out to eliminate.
Changes:
- Fetch mobile OAuth data by state unconditionally; a state with no
pending oauth_requests row redirects to /?oauth_error=invalid_request
WITHOUT clearing cookies — forged callbacks can no longer kill an
in-flight login or reach the app
- Mobile error redirects now use the server-stored redirect URI as the
sole qualification and target (still allowlist-checked); the cookie is
a secondary binding check, no longer a prerequisite
- Clamp outgoing error codes to the RFC 6749/OIDC set; unknown codes
become server_error and drop the description
- ProcessCallback failures redirect to /?oauth_error=server_error
instead of a raw 400 page leaking internal error text
- Binding cookies fail closed on a partial pair, matching the success
path; web error redirects honor PublicURL in production
- Log previously-silent failure branches (undecodable cookie, store
lookup errors) and tag web fallbacks with had_mobile_cookie so
degraded-mobile flows are greppable
- Correct doc comments that claimed guarantees the code didn't enforce
- Add handlers_error_redirect_test.go (33 subtests): forged callbacks,
cookie-less mobile rescue, clamping, partial binding pairs, and both
ProcessCallback-failure variants at handler level
Known follow-ups (out of scope here): post-exchange failure branches
(nil session, DID lookup, handle mismatch, seal failure) still serve
static error pages instead of returning to the app; flow-start handlers
still echo %v of internal errors; custom-scheme deep links remain
hijackable by design until Universal/App Links land.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-model review follow-up to 0feddc1. The MIME guard only rejected
empty strings while its own error text promised concrete types, the
validation error was untyped (surfaced as a 500 via the caller's
errors.Is switch), and result.Blob was dereferenced unchecked — a 2xx
with a missing blob panics, and a blob missing its ref yields a
zero-value CID whose String() is the garbage "b", silently storing a
broken avatar past the handler's nil check.
Changes:
- Reject wildcard MIME types ("*/*", "image/*") at the client boundary,
not just empty ones, so the confusing remote ScopeMissingError can't
recur from a future caller
- Wrap ErrBadRequest in the validation error so callers classify it as
a 400-shaped failure
- Validate the uploadBlob response: nil Blob or undefined Ref returns a
classified error instead of panicking / storing a zero CID
- Add TestClient_UploadBlob (httptest, 9 cases): outbound Content-Type
equals the caller's MIME type, empty/wildcard rejection with zero HTTP
requests, 403 -> ErrForbidden, malformed-2xx handling
Deliberately NOT added: an image-MIME allowlist inside the client — the
PDS client is generic blob transport; content policy stays in the
handlers, which already enforce it on both call paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author avatars/display names never rendered anywhere except the standalone
profile endpoint, and ~870 pre-relay-switchover users had permanently bare
profiles because their social.coves.actor.profile firehose event was dropped
while bsky.network throttled the bridge PDSs (profile records are written once
at rkey "self" — a missed create event never replays).
Gap 1 — views never selected author profile columns:
- postViewSelectColumns now includes u.display_name/u.avatar_cid/u.pds_url and
is the single source of truth for every PostView query: the six hand-copied
feed SELECT blocks collapse into feedPostSelectClause, and scanFeedPost
delegates to the shared scanPostView (variadic extraDest carries hot_rank)
- comment views hydrate author display name/avatar from the already
batch-loaded user rows (was hardcoded nil); thread-root posts too
- side effects of the unification: feeds now populate editedAt (was scanned
and dropped), and malformed embed JSON omits the record key instead of
serializing "embed": null
Gap 2 — no reconciliation for missed profile events:
- users.FetchProfileRecord fetches actor.profile/self from the user's own PDS
via com.atproto.repo.getRecord (1 MiB body cap, quoted error bodies, strict
RecordNotFound discrimination — bare 404s from non-PDS hosts are errors)
- IndexUser backfills users indexed with a completely empty profile: sync
emptiness check, detached fetch goroutine (WithoutCancel + 10s timeout) so
OAuth callbacks and firehose consumers never block, pre-write re-check so a
concurrent firehose event wins; opt-in via WithProfileBackfill, wired in prod
- cmd/backfill-profiles: re-runnable reconciliation job for existing bare
users (dry-run, bounded concurrency, atomic stats, exit 1 on failures)
Hardening from multi-model review: fetched displayName/bio rune-truncated to
the DB CHECK limits (hostile PDS could otherwise wedge backfill in a permanent
fetch-fail loop), implausible blob CIDs rejected, HydrateImageURL no longer
warn-floods on empty CIDs, social.coves.actor.profile constant consolidated to
users.ProfileCollection.
Tests: 20+ new unit tests (fetch classification, truncation, async backfill
semantics, comment/post author hydration) plus an integration test pinning
author hydration through GetViewsByURIs, GetByAuthor, and community feeds,
including the author-DID-in-URL and bare-author-stays-bare cases.
Also gitignores .claude/agent-memory/ (per-machine review-agent state).
Deploy note: run cmd/backfill-profiles once against prod (with -dry-run first)
to heal existing bare users.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While unset, dev mode generates a random seal secret on every appview
boot, so each restart invalidates all sealed mobile tokens and kills
every mobile session (this bit us repeatedly). Set a persistent
localhost-only value and document why it must stay set.
Also add a header note making explicit that .env.dev is intentionally
tracked: every credential in it is localhost-only, and production
secrets belong in the gitignored .env.prod.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancelling sign-in or denying consent on the PDS authorization page
redirects back to /oauth/callback with ?error=access_denied — which the
handler fed to ProcessCallback and answered with a raw 400 text page at
127.0.0.1:8081, stranding mobile users outside the app with no way back.
Handle authorization-server errors before the code exchange: mobile flows
302 to the app's redirect URI with the error params
(social.coves://callback?error=access_denied&error_description=...) so
the app regains control; web flows go back to / with ?oauth_error=.
Unexpected ProcessCallback failures also redirect mobile flows (generic
server_error code — details stay in the server log).
The redirect target is never attacker-controlled: the server-side stored
redirect URI (keyed by OAuth state) is preferred over the cookie, the
cookie CSRF binding must validate when present, and the final URI must
pass the same allowlist as the success path. No tokens travel on this
path — only the error code the authorization server already showed.
Verified via Playwright against the dev stack: Cancel on the sign-in page
and Deny on the consent page both now 302 to
social.coves://callback?error=access_denied&error_description=The+user+rejected+the+request
with the mobile cookies cleared, and the authorize-and-accept happy path
still completes with a sealed token deep link.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Indigo's generated RepoUploadBlob helper hardcodes Content-Type: */* (the
lexicon's accepted encoding). A PDS enforcing the granular blob:*/* OAuth
scope matches the granted accept patterns against the request's
Content-Type and requires a concrete MIME type — a wildcard never matches
(oauth-scopes' matchesAnyAccept rejects any mime containing '*'), so every
upload through the generated helper failed with ScopeMissingError
"Missing required scope \"blob:*/*\"" even on fresh sessions whose grant
carried blob:*/*. The error's quoted scope is derived from the attempted
content type, which is why it reads exactly like the granted scope.
Call LexDo directly with the caller-supplied mimeType (which handlers
already validate as a concrete image type) instead of the generated
helper, and reject empty mime types at the client boundary.
Verified against the dev stack: fresh OAuth login as mari, then
social.coves.actor.updateProfile with a PNG avatar — PDS log shows
content-type: image/png -> 200 where it previously showed */* -> 403
ScopeMissingError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every PDS error switch lumped pds.ErrForbidden (403) in with
ErrUnauthorized (401) and answered HTTP 401 with AuthExpired/AuthRequired
— the exact signal clients use to destroy the session. So an avatar
upload rejected for a missing blob:*/* scope signed the user out of a
perfectly valid session. The scope gap itself hits sessions whose OAuth
grant predates c65da94 (which added blob:*/* and the granular repo:
scopes): token refresh preserves the original grant's scopes, so those
sessions mint fresh access tokens without blob:*/* until the user fully
re-authenticates.
New contract: 401 → AuthExpired/AuthRequired (session dead,
re-authenticate); 403 → PermissionDenied with HTTP 403 and a message
explaining that signing out and back in re-grants the permission. The
updateProfile lexicon defines no error names, so PermissionDenied is a
compatible addition.
Changes:
- Split ErrForbidden from ErrUnauthorized in all five mapping sites:
update_profile.go (avatar upload, banner upload, putRecord),
userblock/errors.go, community/errors.go
- Update PutRecordForbidden and BannerUploadForbidden tests to assert
the new 403/PermissionDenied mapping
- Add AvatarUploadForbidden test (path previously uncovered)
Frontend follow-up (coves-frontend, coves-mobile): sign out only on 401;
surface 403 PermissionDenied as a message with a reconnect option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The social.coves.community.get lexicon promises viewer state when the
caller is authenticated, but the endpoint never delivered it: the route
was registered without OptionalAuth middleware (so the caller's DID was
never resolved) and the handler never invoked the viewer-state helper.
Subscribing to a community then reloading its page reverted the button
to "Subscribe", making it impossible to unsubscribe from the community
page. community.list already did this correctly; this copies its exact
pattern.
Changes:
- Add OptionalAuth middleware to the community.get route so the
authenticated caller's DID reaches the handler
- Pass the communities repository into GetHandler and populate
viewer.subscribed via common.PopulateCommunityViewerState before
serializing (ToCommunityViewDetailed already carried the field)
- Add integration test covering subscribed=true, subscribed=false, and
viewer omitted for unauthenticated requests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jetstream emits identity events without a handle when an identity's
handle is invalidated or tombstoned — valid events, and frequent at
network scale. The users consumer rejected them as permanent failures,
and since the dead-letter pipeline shipped, every one became a junk DLQ
row (~tens of thousands/day observed on prod within minutes of the
multi-feed deploy). We index only known users and have no handle-invalid
state to record, so there is nothing to apply: skip cleanly. A missing
DID remains a permanent structural rejection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The AppView now consumes N Jetstream feeds carrying the same repos: the
public bsky.network Jetstream plus our self-hosted relay+Jetstream pair
(tidepool stack) that crawls tdpl.io + pds.coves.me with no per-host
account quotas. Feeds are internally ordered but skewed by hours, so a
lagging feed replays a repo's history AFTER newer events were applied —
resurrecting deleted comments/votes, regressing edits, re-subscribing
unsubscribed users. The existing time_us recency guards cannot catch
this: each feed stamps its own emission time, so a stale copy arrives
with a NEWER timestamp than the state it would clobber.
The keystone is rev-gating: every commit event carries rev, the repo's
monotonic TID. jetstream_record_revs (migration 033, COLLATE "C") stores
the last APPLIED rev per record URI; create/update/delete apply only if
strictly newer. Equal rev = duplicate replay (no-op, subsumes existing
duplicate handling); the gate row survives hard deletes as the tombstone
that rejects stale creates. One rule, no heuristics, no CID comparisons.
Changes:
- rev_gate.go: conditional-upsert gate primitives + RevGate + applyGated
(transactional claim held across apply; same-URI handlers serialize on
the gate row lock, apply failure rolls back un-advanced for redrive)
- posts/comments/votes: gate as first statement of existing transactions;
delete paths restructured gate-first so the tombstone commits atomically
with the deletion (closes the not-found-delete vs concurrent-create race)
- users/communities/aggregators: injected RevGate; deletes tombstone even
for never-indexed records; comments re-apply genuinely-newer same-rkey
re-creates on active rows; SubscribeWithCount conflict path updates
record_uri/cid last-write-wins (cross-rkey redriven-delete safety)
- feeds.go: JETSTREAM_FEEDS="bsky=…;self=…" replaces six per-consumer URL
env vars (now fatal at boot with migration hint); per-consumer collection
filters derived in code via WantedCollections (unknown name = fatal, no
more filterless whole-firehose subscriptions); "bsky" feed keeps legacy
consumer names so live cursors carry over; @self consumers live-tail
- fail-closed boot checks: multi-feed refuses ungated consumers
(RevGated()), missing JETSTREAM_FEEDS fatal outside dev, warning when
no primary feed key is configured
- fixes latent drift: community.block was never in the subscribed
collections; users consumer subscribed to the entire firehose in prod
- tests: adversarial cross-feed interleavings (zombie create, stale update
clobber, phantom vote, delete-before-create tombstones for votes AND
posts, subscription zombie, profile stale-replay, gate semantics) +
feeds parsing suite; Makefile test target runs packages sequentially
(-p 1) so the integration suite's unscoped table wipes can't race other
packages on the shared test DB
Known limitations (documented in code): identity events carry no rev so
handle changes are not cross-feed ordered; posts/votes active-row same-
rkey re-creates after a dead-lettered delete keep the old content.
Deploy notes: migration 033 auto-runs at boot. Prod compose sets
JETSTREAM_FEEDS with self=ws://tidepool-prod-jetstream:8080 (relay +
Jetstream deployed 2026-07-17). Expect "rev-gate: skipped stale" log
lines for lagging bsky copies — that is the system working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five Jetstream consumers (posts, votes, comments, communities,
aggregators) plus the user consumer previously resumed at the live tail
after every restart, deploy, crash, or reconnect — events during the gap
were permanently lost from the AppView — and handler failures were
log-and-dropped. Consumers also ran on a never-cancelled
context.Background(), so SIGTERM killed them mid-transaction, and there
was zero visibility into indexing health.
One shared jetstream.Connector now replaces the five byte-identical
per-consumer connector files and the user consumer's inline connection
loop, with the invariant: the cursor advances past an event only if the
handler succeeded OR the event is durably captured in the dead letter
queue; if the dead-letter write itself fails, the connection tears down
without advancing so the event replays. Verified end-to-end against live
infrastructure: kill/restart resumes all six consumers from the exact
persisted cursor.
Changes:
- Cursor persistence (jetstream_cursors, migration 032): consumers dial
with &cursor= rewound 5s per Jetstream gapless-playback guidance;
monotonic at three layers (in-memory guard, flush check, SQL WHERE);
flushed every 5s and on shutdown. All handlers verified/made
replay-idempotent, with duplicate-delivery tests for the
count-mutating vote/comment paths.
- Retry then dead-letter (jetstream_dead_letters): 3 in-line retries
with backoff, then the raw frame is captured (BYTEA, so byte-corrupt
frames can't wedge a consumer) with a dedup unique index so rewind
replays can't mint duplicate rows with fresh retry budgets.
- Error taxonomy: permanent rejections (validation/security failures)
wrap jetstream.ErrPermanentEvent — no retries, dead-lettered born
exhausted; ordering-dependent failures (post-before-community) stay
transient so the redriver heals them.
- DeadLetterRedriver: replays transient dead letters every 5 minutes
(boot pass included, drains full backlog per tick, max 10 attempts,
shutdown doesn't burn attempts).
- Stale-redrive recency guards: post/comment/profile updates carry a
time_us watermark so a redriven old edit can never overwrite a newer
one (posts guard is atomic in the UPDATE WHERE clause).
- Graceful drain: consumers run on a cancellable context with a
WaitGroup; SIGTERM unblocks read loops, abandons in-flight events
without advancing the cursor, and flushes cursors before exit.
- GET /health/consumers: per-consumer connection state, cursor age,
processed/dead-lettered counts, DLQ backlog; 503 when stalled,
degraded when the backlog count is unavailable; /health stays a pure
liveness probe so a Jetstream outage can't restart-loop the AppView.
- Hardening: 16MiB WebSocket read limit, double-Start guard, ping-failure
fast teardown, consumer-name constants keying persisted state.
- Tests: connector suite against a real WebSocket server (cursor resume,
rewind redial, DLQ-write-failure freeze, shutdown flush), Postgres
state-store suite (monotonicity, dedup, binary payloads), taxonomy
tests per consumer, recency-guard tests, health-handler tests.
- Fixed TestVoteE2E_JetstreamIndexing subscribe-after-write flake by
subscribing with a pre-write cursor so Jetstream replays the event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since federating with Lemmy, bridged posts with large-population vote counts
(e.g. 782 likes, 2 days old) dominated discover/hot for up to a week while
fresh organic Coves posts with 0 votes sank. The linear Reddit-style numerator
was the cause: vote counts scale with instance population, so cross-instance
comparison on a linear scale is meaningless.
New formula (was: (score + 1) / (age_hours + 2)^1.5):
(SIGN(score) * LN(ABS(score) + 1) + 1) / (GREATEST(age_hours, 0) + 2)^1.5
Tuning (pinned by integration test):
- Fresh 0-vote post outranks a day-old 782-vote blowout (0.354 vs 0.058)
- Day-old 782-vote post still outranks a 6h-old 0-vote post (0.058 vs 0.044)
- A blowout dominates brand-new posts only for its first ~6 hours
- Negative-score posts rank below all non-negative posts of similar age
Changes:
- Centralize the hot-rank SQL in hotRankSQL() (feed_repo_base.go); the live
ORDER BY and the cursor pagination comparison now share one builder and can
no longer drift apart (previously 4 hand-synced copies, already divergent)
- Collapse the three identical per-repo sortClauses maps into one shared
whitelist; newFeedRepoBase() drops the per-repo formula/clause params
- Clamp age at 0 in the formula: future-dated created_at (hostile or
clock-skewed federated records) ranks like a brand-new post instead of
erroring the query (negative POWER base 500'd the whole hot feed) or
gaining a rank boost
- SECURITY: clamp future createdAt to now at Jetstream ingestion
(post_consumer.go) - also protects the "new" sort from future-date pinning
- Remove dead nullStringPtr; fix stale hot-rank comments and docs
- Tests: pin the ranking tuning, hot pagination to exhaustion across the
positive/negative rank boundary (live/cursor formula sync), future-dated
post handling; createTestPost now derives vote counts for negative scores
Reviewed via /validate and /second-opinion (7-reviewer fan-out); all findings
addressed except documented pre-existing tech debt (comment hot sort remains
linear by design - same-post voter population).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dns shortcut builds a single Let's Encrypt issuer, and LE's
50-new-certs/week bucket for tdpl.io is already exhausted by the
on-demand per-handle churn — every wildcard obtain 429s with no
fallback (Caddy >= 2.10 no longer defaults to ZeroSSL). Configure the
issuers explicitly: LE first, ZeroSSL second. ZeroSSL reuses the ACME
account the old image's default fallback registered in storage, so no
EAB is needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The block-subdirective form only exists in the doc comment; the v2.11
parser accepts force_automate solely as the first-line argument
(httpcaddyfile parseTLS).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Since Caddy 2.10, a wildcard subject that covers another managed subject
silently wins: *.*.tdpl.io from the on-demand catch-all block covers
every *.<instance>.tdpl.io, so caddytls skipped issuance of all 64
instance wildcards at startup — no log line, no cert, no error
(managingWildcardFor in modules/caddytls/tls.go). force_automate puts
the block's names into the tls.certificates.automate loader, the
documented bypass for that preference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bridged handles are two labels below the apex (name.instance.tdpl.io) and
a wildcard cert covers only one level, so Caddy was issuing a cert per
handle on demand at handshake time — handshake-blocking, and every handle
draws from Let's Encrypt's 50-new-certs/week bucket for tdpl.io. At ~219
bridged repos the bucket exhausted and identity resolvers timed out,
recording handle.invalid for ~200 author identities.
One DNS-01 wildcard per bridged instance (*.lemmy-world.tdpl.io, 64
instances from production bridged_actors) covers every user of that
instance with a single pre-issued cert. The on-demand block stays as the
catch-all for instances not yet listed. DNS-01 needs API write access to
the tdpl.io zone: new CLOUDFLARE_API_TOKEN_TDPL env (Zone:DNS:Edit,
tdpl.io only), separate from the coves.social token so either rotates
independently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A personal address in a public repo invites harvesting. The Caddyfile
now takes {$ACME_EMAIL} from the container environment (set in .env,
which never leaves the server); use a role address.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BridgeTrust gates bridgedStats on communities.pds_url, but firehose-
indexed communities (Tidepool bridge communities above all) were created
with it empty — the gate default-denied every bridged vote count with
'not a trusted bridge PDS'. The consumer already resolves the repo's
identity for the handle; now it stores the identity's PDS host on create
AND on update (COALESCE-guarded in Update() so callers without the value
keep the stored one — this also heals rows indexed before the fix on
their next profile event).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A community.profile omitting visibility is valid (the lexicon declares
default 'public'), but the consumer passed the empty string through to
the INSERT, tripping communities_visibility_check — every Tidepool
community failed indexing at the very last step, after hostedBy
verification had already passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- global email: provisions ZeroSSL as fallback CA (Caddy 2.8+) so an LE
rate-limit hit degrades instead of hard-failing on-demand handshakes
- drop the active health check on the single tidepool upstream: one
failed probe would 503 the whole site with nothing to fail over to
- cap inbox request bodies at 1MB (AP activities are small JSON; media
is fetched outbound, never uploaded)
- drop redundant header_up lines (Caddy passes Host and sets
X-Forwarded-For/-Proto itself)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apex block proxies AP + com.atproto.sync.* to the tidepool container
(joins coves-prod-network from the /opt/tidepool stack). Bridged-handle
subdomains sit two labels below the apex, so a single wildcard cert
cannot cover them: certs are issued on demand per hostname, gated by
Tidepool's /.well-known/tidepool-tls-ask endpoint so wildcard-DNS probes
cannot burn ACME quota. Bridge PDS host examples now use tdpl.io.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BigSky preserves event order within a repo but not across repos, so a
bridge-authored post (in a community repo) can reach the AppView before
the author's actor.profile (in their own repo). Previously such posts
were rejected ("author not found") and bridged profiles for
never-before-seen identities were silently skipped, leaving virtual
bridge users unindexable. This wires a shared, provenance-gated
discovery path across the user and post consumers so bridge identities
enter the AppView safely without opening the door to indexing every
profile on the network.
Changes:
- bridge trust shared across consumers: TRUSTED_BRIDGE_PDS_HOSTS parsed
once in main.go and passed to the user consumer (WithUserBridgeTrust)
and post consumer (WithPostBridgeTrust); default-deny when unset
- user consumer: profile create/update for an unknown DID now resolves
identity and indexes only when the resolved DID matches and its PDS is
a trusted bridge; native unknowns still ignored (avoid-indexing-the-
world policy preserved); DB errors now propagate instead of updates
being dropped
- post consumer: on ErrUserNotFound, resolve the author and minimally
IndexUser only when hosted by a trusted bridge (WithPostIdentityResolver);
untrusted/native authors still rejected
- post consumer now indexes CREATE/UPDATE/DELETE (log line corrected)
- config: TRUSTED_BRIDGE_PDS_HOSTS documented in .env.prod.example and
wired through docker-compose.prod.yml
- tests: cross-repo ordering (post-before-profile) indexes trusted
bridge author; unknown profile hosted by trusted bridge is indexed;
test doubles updated (mock resolver identities map, IndexUser records)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bridged (Lemmy-origin) vote counts now arrive as an optional
bridgedStats {upvotes, downvotes, asOf} field on post/comment records
emitted by the tidepool bridge. Coves folds them into displayed counts
and score so bridged content ranks natively, gated on provenance so
only bridge-managed repos may assert aggregate counts.
Changes:
- lexicons: social.coves.community.post/comment gain #bridgedStats
(upvotes, downvotes, asOf — all required within the optional field)
- migration 031: bridged_upvote_count / bridged_downvote_count
(CHECK >= 0) + bridged_stats_as_of on posts and comments
- provenance gate (jetstream/bridge_trust.go): bridgedStats applied
only when the repo's stored pds_url matches TRUSTED_BRIDGE_PDS_HOSTS
(default-deny; content still indexes with the field ignored
otherwise); input hygiene regardless — negatives rejected, 1M
magnitude cap mirroring the bridge's MaxSeededCount
- post consumer: new UPDATE handler (updates were previously silently
dropped — bridged post edits never re-indexed): create-parity
security validation, community/author reassignment rejected,
soft-delete skip, atomic newer-or-equal asOf guard in SQL, edited_at
bumped only on real content changes, RowsAffected checked
- comment consumer: same gate + guard on its update path; soft-deleted
comments skipped; resurrection recomputes score from surviving
native counts
- score is inclusive — (native+bridged up) − (native+bridged down) —
at every write site; flip-decrement now uses the same clamped
arithmetic as the count columns; hot/top sort expressions, cursors,
and indexes untouched
- read paths fold bridged counts into displayed up/down everywhere
(incl. comment GetByURI; the consumer's guard reads raw columns)
- log hygiene: infra errors no longer logged as security rejections;
false "Jetstream will replay" comments corrected (connectors are
log-and-drop, no cursor)
Deploy note: TRUSTED_BRIDGE_PDS_HOSTS must name the bridge's PDS host,
or bridgedStats is ignored everywhere (safe default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a parentRkey parameter to social.coves.community.comment.getComments so
clients can fetch a single comment's subtree instead of the whole thread. When
parentRkey is provided the response contains exactly one top-level
threadViewComment — the comment with that rkey within the post — with its
descendants nested beneath it. depth is relative to that comment, and
limit/cursor paginate its direct replies through the same hot-rank listing used
for top-level comments. This backs the frontend comment permalink page
(/c/<community>/post/<rkey>/comment/<crkey>) and lets "load more replies" fetch
just the relevant subtree.
Changes:
- getComments: resolve parentRkey via an indexed root_uri + rkey lookup
(GetByRootAndRkey). Unknown rkeys return 404 ParentNotFound; malformed rkeys
are rejected at the handler and re-validated in the service. rkeys are TIDs,
so cross-commenter collisions within one post are astronomically unlikely; if
one occurs the earliest-indexed comment wins. Deleted parents return their
tombstone placeholder with children intact.
- Author-block filtering: GetByRootAndRkey and the children listing take a
viewerDID; a permalink to a comment whose author the viewer has blocked
returns ParentNotFound, matching the thread and feed read paths.
- Error propagation: buildThreadViews returns an error instead of logging and
returning a silently truncated tree, so a transient batch-load failure
surfaces as a real request error rather than "no replies" with a misleading
HasMore=false.
- Cursor errors: new ErrInvalidCursor sentinel; all repository cursor parse
failures wrap it so the handler maps them to HTTP 400 InvalidRequest with a
fixed message instead of leaking internal detail. depth=0 combined with a
cursor (which can never be honored) is rejected.
- Lexicon: add parentRkey (format record-key) to getComments.
- Tests: unit coverage for subtree return, depth semantics, ParentNotFound,
children pagination, blocked-author filtering, repo-error-not-masked-as-404,
viewer DID plumbing, depth0+cursor rejection, and cursor/batch error
propagation; integration coverage for deleted-parent placeholder, viewer
block, rkey-collision determinism, and RootNotFound precedence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post.create handler accepted req.Embed as an untyped map and wrote
it to the PDS verbatim. A malformed embed (e.g. a bare {"uri":"..."}
with no $type discriminator) was silently persisted as an unrenderable
record — readers key off $type and drop it, so the user's link vanished
with no error anywhere in the pipeline.
Add validateEmbed(), called early in validateCreateRequest, which
enforces the post.create lexicon union: a recognized $type plus the
required nested field(s) for each member (external.uri; non-empty
images[] each with an image blob object; video blob; post.{uri,cid}).
Malformed embeds now return a ValidationError → HTTP 400 InvalidRequest
at the API boundary instead of corrupting the repository. Unknown and
output-only #view types are rejected fail-closed.
Changes:
- Add internal/core/posts/embed_validation.go with validateEmbed()
- Wire validateEmbed() into validateCreateRequest (service.go)
- Add embed_validation_test.go: 28 table cases covering every union
member's happy path and failure modes (incl. the bare-{uri} bug)
- Add TestPostHandler_EmbedValidation integration test proving the
validation is wired into the real handler->service create path
- Make TestPostHandler_ThumbValidation idempotent: add a scoped
pre-run cleanup so it no longer depends on collateral deletes from
earlier-sorted tests when run in isolation against the persistent DB
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-on hardening for social.coves.community.post.get: tighten the
batch-fetch SQL, make the union-member guard fail loud on assembly bugs,
and add the missing handler/integration coverage (postView, notFoundPost,
and the viewer author-block blockedPost path against live infra).
Changes:
- post_repo: replace the interpolated IN-list in GetViewsByURIs with a
static `= ANY($1)` (pq.Array) query so the SQL is constant, fully
parameterized, and yields one cached plan regardless of batch size
- post_repo: extract postViewSelectColumns as the single source of truth
for the SELECT list shared by GetViewsByURIs and GetByAuthor, so the
two queries cannot drift out of sync with scanPostView's positional Scan
- posts.Member: treat "more than one member set" as invalid (count != 1),
surfacing a mis-assembled result as an internal error instead of
silently picking one and emitting an ambiguous union entry
- routes: fail fast at registration if oauthMiddleware is nil (it backs
OptionalAuth for the public post.get endpoint) instead of panicking on
the first request
- get_test: add handler tests for over-length URI (400, service not
called), service-error->status mapping (validation 400 / internal 500),
viewer-DID passthrough, and duplicate-URI handling
- service_get_posts_test: add multiple-member-set cases for Member
- user_journey_e2e: wire the real userblocks BlockChecker into the journey
post service (as cmd/server/main.go does), then add Part 3b (live
postView + notFoundPost, request order) and Part 3c (a viewer records an
author-block and post.get returns a blockedPost with no content leak),
giving live-infra coverage of all three post.get union members
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the critical review item: GetPostsRequest.ViewerDID was threaded
through but unused, so the get-by-URI (permalink/cold-load) path returned posts
the feed/timeline paths would hide for a viewer who blocked the author. The
lexicon already defines #blockedPost in the output union, so this wires it up
rather than dropping the field.
- posts.BlockChecker interface (satisfied by userblocks repo's AreBlocked),
injected via new WithBlockChecker functional option (no churn to ~20 existing
NewPostService callers)
- GetPosts now batches a block lookup over unique author DIDs when a viewer is
present and rewrites blocked authors' posts to BlockedPost (blockedBy:author);
fails closed on lookup error so unverified content is never surfaced
- author-only for now: community blocks aren't enforced on any read path, and
moderator removals already surface as notFoundPost (soft-deleted rows absent)
Coupled type-design/union fix:
- PostResult gains a Blocked variant + exhaustive Member() accessor; the handler
now 500s on an unset result instead of emitting a null union member (200 OK)
- result constructors set the const discriminators (notFound/blocked == true)
Tests: blocked/unblocked/not-found ordering, author-DID dedup, skip when no
viewer or no checker, fail-closed on error, Member() exhaustiveness, and handler
tests for the blockedPost member + invalid-result 500.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Batch-fetch post views by AT-URI for feed hydration and permalink/cold-load
rendering. Returns posts in request order; missing or soft-deleted posts come
back as notFoundPost markers.
- posts.Service.GetPosts + Repository.GetViewsByURIs (deduped batch fetch,
canonical DID-URI validation, request-order assembly)
- GET handler at /xrpc/social.coves.community.post.get with per-URI length
caps and the shared batch-size limit
- viewer vote-state enrichment, blob-ref and embed transforms on found posts
Baseline reviewed via /second-opinion; review fixes follow on this branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration E2E suite intermittently failed under `make test-all` (yet passed
in isolation) whenever a test subscribed to the cursorless Jetstream firehose
AFTER issuing its PDS write. A cursorless subscription only streams commits
emitted after the socket is dialed, so under full-suite CPU load the commit was
relayed before the subscriber connected and was lost, hanging the test until its
deadline. This is a test-only race — the production AppView consumer is a
long-lived connection that is always listening first.
Two fix patterns, applied per case:
- Cursor replay (helpers.go: jetstreamCursorNow + withJetstreamCursor): capture a
time_us cursor immediately before the write, then subscribe with it so Jetstream
replays past the race. Used where the entity DID is generated by the write
itself (community_e2e Part 2, user_journey Parts 2 & 3, post_e2e Part 2). Safe
by construction: Jetstream stamps time_us at ingest (after the write), so the
cursor sits below our event and above any earlier one; the shared host clock
means no skew margin is needed.
- Subscribe-before-write (user_profile_avatar ReplaceAvatar): replaced the dead
goroutine + dial-after-write helper with subscribeForProfileEvent, which dials,
signals readiness, then returns a wait closure invoked after the write. Window
widened 15s -> 30s to match siblings.
Also swept all remaining modulo-based test handles (% 10000 / UnixNano() % N) onto
the collision-free uniqueTestID() helper across user_profile_avatar, user_journey
(handles + deferred cleanup patterns sharing one id), and
community_service_integration, removing the "Handle already taken" re-run risk
against the persistent PDS.
Validated: make test-all fully green; 7 no-cache integration runs + 3 E2E runs,
zero failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
make test-all was failing across several E2E tests against real infra. The root
causes were independent latent bugs that only surface against live Postgres/PDS:
- community suggestions: the pagination and status-transition subtests created
more than 3 suggestions as a single user, tripping the 3/day rate limit (429).
Spread creation across distinct submitter DIDs (also more realistic, since
listing/status are over all submitters). The dedicated rate-limit test is
left intact.
- kagi unfurl: kite.kagi.com was intentionally made unsupported in f8efe46, but
TestPostUnfurl_KagiURL still asserted it was supported. Inverted it into
TestPostUnfurl_KagiKiteExcluded, which asserts the exclusion is enforced
(and drops the flaky network dependency).
- PDS handle collisions: ~24 handles used time.Now().UnixNano()%1000000, which
collapses to 6 digits and collides against accounts persisted by prior runs
("Handle already taken"). Routed all PDS handles through the uniqueTestID()
helper and switched uniqueTestID() to base36 (Unix-seconds + atomic counter)
so prefixed handles stay under the 18-char PDS label cap ("Handle too long")
and stay collision-free across runs.
- firehose panic: 4 Jetstream read helpers (vote/comment/post_delete/post_e2e)
kept calling ReadJSON after a read-deadline timeout, which gorilla/websocket
treats as a permanent read failure; after 1000 dead reads it panics and
crashes the whole test binary. Added the established consecutiveTimeouts
giveup guard to match the already-guarded helpers.
Test-only changes. `go build ./...` and `go vet ./tests/integration/` are clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caddy's `header` directive REPLACES the upstream Content-Security-Policy rather
than merging, so the site-wide CSP was overriding the tighter, Cloudflare-
allowing CSP that TurnstileHandler sets on the widget host page — blocking
Cloudflare's challenge api.js and breaking the captcha. Apply the global CSP to
every path except /m/turnstile.html, letting the AppView's per-page policy stand.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a bot-protected signup handshake so new accounts must pass a Cloudflare
Turnstile challenge before a PDS invite code is issued. The mobile app loads a
server-rendered widget page, solves the challenge, and exchanges the token with
the AppView, which verifies it and mints a single-use invite via the PDS admin
API. Signup stays closed (not bypassed) when the feature is unconfigured.
Changes:
- New social.coves.actor.requestSignupToken XRPC endpoint: verifies a Turnstile
token then mints a single-use invite (com.atproto.server.createInviteCode),
rate-limited 5/min/IP on top of the global limit
- New TurnstileVerifier interface + Cloudflare impl; fail-closed on any
transport/decode/verification error, raw token never logged
- New /m/turnstile.html widget host page for the mobile WebView, CSP locked to
Cloudflare's challenge origin; returns 503 when TURNSTILE_SITE_KEY is unset
- Distinct, observable error types so ops can alert on misconfig/outage apart
from user rejection: ErrSignupTokenDisabled/CaptchaUnavailable/
ErrPDSAdminUnavailable (503), InvalidCaptchaError (403), InviteMintError (500)
- Harden rate limiter: export GetClientIP, make it XFF-spoof resistant (prefer
X-Real-IP, else rightmost XFF, else RemoteAddr sans port); named limiters for
attributable 429 logs; Stop() to end the cleanup goroutine; log every 429
- Replace error-string matching with the ErrUserAlreadyExists sentinel (errors.Is)
- Wire TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY/PDS_ADMIN_PASSWORD through main.go,
docker-compose, and .env files (Cloudflare always-pass test keys in dev)
- Remove the beads (bd) issue tracker: delete AGENTS.md, drop Issue Tracking from
CLAUDE.md, gitignore .beads/
- Add unit/e2e tests: turnstile, signup-token handler, rate limiter, user errors,
turnstile template, signup-token e2e; update all NewUserService call sites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous fix disabled the unfurl-thumbnail fallback for all trusted
aggregators to stop kagi from inheriting kite.kagi.com's misleading default
og:image. That also killed streamable.com thumbnails for the reddit-highlights
aggregator, which intentionally sends only the URL and relies on the server's
oEmbed unfurl to fetch the thumbnail.
Move the exclusion to the actual source of the problem: kite.kagi.com itself.
Its server-rendered <title>, og:title, og:description, and og:image are all
the same default fallback for every URL (the top story, randomly localized
by path), so unfurling produces wrong metadata regardless of caller.
This restores the original two-mode unfurl behavior (full for users,
thumbnail-only for trusted aggregators) and makes kite.kagi.com explicitly
unsupported in IsSupported. Result:
- reddit-highlights + streamable → oEmbed thumbnail restored
- kagi w/ primary_image → unchanged (aggregator thumbnailUrl path)
- kagi w/o primary_image → no unfurl (kite unsupported), no wrong image
- regular user pasting a kite URL → no enrichment, but no wrong one either
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trusted aggregators are authoritative on embed metadata. Falling back to
unfurl when a trusted aggregator omitted a thumbnail meant scraping the
kite.kagi.com SPA, which returns the same default og:image for every URL —
so every image-less Kagi story was getting the same wrong thumbnail (e.g.
the top story's image attached to unrelated posts further down the feed).
Now: no thumbnailUrl + trusted aggregator = no thumbnail. The existing
"No fallback - aggregators only use RSS feed thumbnails" comment finally
matches the behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the brittle HTML-description parser with a join against Kagi's sibling
JSON feed. RSS XML still supplies the authoritative GUID/link/pubDate/categories;
the JSON cluster supplies pre-structured short_summary, talking_points,
perspectives, quote, and articles, eliminating the HTML-scraping surface area.
Also add inline-citation rendering: Kagi embeds [domain#N] markers in its
structured fields, indexed 1-based within each per-domain bucket of
cluster["articles"]. These now become inline link facets in rich text and are
cleanly stripped from plain-text embed.description and dedup input.
Changes:
- Replace src/html_parser.py with src/json_parser.py (KagiJSONParser); delete
the HTML parser, fixture, and tests
- Add src/citations.py: build_index / strip / tokenize for [domain#N] markers,
with [common] non-link marker handling and unresolved-marker warnings
- Add JSONFetcher in src/rss_fetcher.py with retry + payload validation; add
max_retries >= 1 guard to RSSFetcher and JSONFetcher
- Main: derive sibling .json URL from configured .xml URL; resolve XML->JSON
clusters via cluster_number+1 fast path with title-scan fallback and a
0-resolved tripwire; convert published_parsed -> tz-aware datetime; use
citation-stripped summary for embed.description, dedup input, and state
- Richtext formatter: emit citation markers as link facets across summary,
highlights, perspectives, and quote; add add_italic_span so quotes wrapping
inline citation facets stay italic as a whole; suppress orphan " - " when
quote attribution is empty
- Tests: add test_json_parser.py, test_citations.py, JSONFetcher tests,
citation-handling tests across formatter and main (195 passing, 90% cov)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The deploy command references production host details and is only
useful locally, so it should not be tracked in the repo.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hardcoded /us/ segment locked Italian (and other non-US) visitors to
the US storefront, producing a broken link. Removing it lets Apple
auto-redirect each visitor to their local storefront.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Coves is now live on the App Store and Google Play, so the landing page
should link out instead of showing "Coming soon" placeholders.
Changes:
- Replace placeholder URLs with real store listings
(App Store id6758530907, Google Play social.coves)
- Convert placeholder <div> buttons to <a> tags with target="_blank"
and rel="noopener", restoring "Download on the" / "Get it on" labels
- Remove the now-unused .app-button-placeholder CSS rules
- Update template tests to assert the configured store URLs render
in place of the removed "Coming soon" copy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The semantic deduplication feature added in ae84bee uses the Anthropic
SDK, which requires ANTHROPIC_API_KEY at runtime. Cron runs in a
separate environment and only sees variables written to
/etc/environment by the entrypoint. The previous whitelist only
included COVES_ and PATH, so the dedup step failed under cron.
Changes:
- Add ANTHROPIC_ prefix to the printenv whitelist in docker-entrypoint.sh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Install anthropic>=0.40.0 in the kagi-news aggregator Dockerfile to
support the semantic deduplication feature that uses Claude Haiku
(added in ae84bee).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds an LLM-based dedup layer to catch stories that share an event but
differ in GUID/headline (same report from different outlets, rewrites,
etc.). Exact-GUID filtering still runs first; semantic dedup only
considers stories within the same feed posted in a configurable
lookback window.
Changes:
- New SemanticDeduplicator using claude-haiku-4-5 with a tool-use
schema for structured duplicate reports; fails open on transient
Anthropic API errors so posting is never blocked by network issues
- DedupConfig (semantic_enabled, similarity_threshold, lookback_days)
with validation, wired through ConfigLoader and config.example.yaml
- Aggregator reworked into three phases: GUID filter -> semantic
filter -> post, with per-feed counters for new/failed/exact/semantic
- StateManager.mark_posted now stores title + summary_snippet, and
get_recent_stories returns the lookback window for comparison;
state writes are now atomic (tempfile + os.replace)
- Requires ANTHROPIC_API_KEY when semantic_enabled is true
- Tests: unit tests for SemanticDeduplicator, state manager recent
stories + atomic write, and main pipeline integration; live test
suite gated behind a `live` pytest marker (excluded by default)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement a complete community suggestions feature allowing users to propose
ideas and vote on them. This is off-protocol (not stored on PDS/firehose)
and uses PostgreSQL directly for storage.
Changes:
- Add CRUD endpoints for community suggestions (create, get, list)
- Add voting with toggle semantics and atomic vote count updates
- Add admin-only status management (open/under_review/approved/declined)
- Add rate limiting: 3 suggestions/day per user, 10 req/min create, 30 req/min vote
- Add PostgreSQL migration (030) with community_suggestions and suggestion_votes tables
- Add repository with row-level locking for consistent vote counting
- Extract shared xrpc.WriteError() helper, refactor adminreport to use it
- Add Caddy proxy port (8080) to mobile port forwarding script
- Add comprehensive E2E integration tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
XRPC method names in atProto follow camelCase convention. The getProfile
endpoint was incorrectly using all-lowercase "getprofile".
Changes:
- Rename route from social.coves.actor.getprofile to social.coves.actor.getProfile
- Update startup log message, handler comment, and docs
- Update all E2E and integration test URLs and error messages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Close the network-use loophole so forks running as hosted services
must share their modifications.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Narrow the Caddy /api/* catch-all to only proxy /api/me to the Go
backend, since SvelteKit now owns /api/auth/* and /api/proxy/*. Update
the dev script to auto-detect the coves-frontend directory (falling
back to kelp) and switch from npm to pnpm.
Changes:
- Replace broad /api/* proxy rule with specific /api/me route
- Add coves-frontend directory auto-detection in web-dev-run.sh
- Switch frontend dev server from npm to pnpm
- Normalize Caddyfile.dev indentation to tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Refactors the mobile-only OAuth redirect URI system into a configurable
allowlist that supports both mobile apps (custom schemes + Universal Links)
and web clients (HTTPS redirects). Adds Caddy-based reverse proxy for web
frontend development, enabling same-origin cookie sharing between Vite
frontend and Coves backend.
Changes:
- Refactor isAllowedMobileRedirectURI() into OAuthHandler.isAllowedRedirectURI()
with BuildAllowedRedirectURIs() builder for the configurable allowlist
- Add smart redirect: HTTPS clients get direct HTTP redirect, custom scheme
clients get the intermediate redirect page
- Add Caddyfile.dev reverse proxy config (Vite :5173 + Coves :8081 on :8080)
- Add scripts/web-dev-run.sh for combined backend+frontend dev startup
- Add Makefile targets: run-web, web-proxy, web-proxy-bg, web-proxy-stop
- Auto-run db-migrate before server start in make run and make run-web
- Update OAuth client comments to clarify ATProto loopback client_id spec
- Add PAR request debug logging in dev auth resolver
- Update all security tests to use OAuthHandler instance methods
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement one-directional user blocking following the atProto write-forward
pattern. Blocks are written to the blocker's PDS repo and indexed via the
Jetstream firehose consumer into a new user_blocks table.
Changes:
- Add social.coves.actor.block lexicon and DB migration (029)
- Add userblocks domain package (service, repository, interfaces, errors)
- Add XRPC endpoints: blockUser, unblockUser, getBlockedUsers
- Extend Jetstream consumer to index block create/delete events
- Filter blocked users' posts from timeline, discover, and community feeds
- Filter blocked users' comments from comment threads
- Hydrate viewer.blocking on profile responses for authenticated viewers
- Refactor test helpers: DRY PDS client factories, atomic counters
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Every handler package carried its own near-identical writeError/
handleServiceError pair. Because each mapped a different subset of errors,
whatever a package forgot fell through to a 500 — most damagingly a dead
OAuth session on post, comment, and vote, which left clients with no signal
to re-authenticate and users stuck on "an internal error occurred" until
they signed out by hand.
The fix spans three layers, because the handler switch was only the visible
half: services were discarding the PDS cause before it ever reached the
boundary, and the expired-session path never produced a PDS error at all.
Changes:
- Add pds.ErrSessionExpired and IsReauthRequired. An unresumable OAuth
session fails at client construction, before any PDS request, so it had no
status to map; factory.go now tags it. Tagged narrowly, on
ErrSessionNotFound only — a store outage or cancelled request must not
read as expired, or a brief database blip would 401 every user in flight.
- Stop services swallowing the cause: 17 sites in votes, comments, and
communities now return fmt.Errorf("%w: %w", DomainSentinel, err) so the
domain sentinel and the PDS cause both stay matchable.
- Add internal/api/xrpc.Mapper, a rule table replacing all 13 switches.
Precedence is re-auth, then domain rules, then shared rules (typed
coreerrors, remaining PDS, request lifecycle), then a fallback. Re-auth
jumps the queue so a domain sentinel mapping to 403 cannot mask a 401.
- Strip pds sentinels from post delete: posts live in the community's repo,
so a rejected community credential must not tell the caller to sign in.
- Replace all 50 err == sentinel comparisons with errors.Is.
- Classify identity resolver errors in ResolveHandleToDID, so an unknown
handle is 404 and a resolver outage is 500 rather than both being 404.
- Delete the duplicate writers: handlers.WriteError, writeJSONError,
writeUpdateProfileError, and three inline switches in update_profile.go.
- Fix along the way: comments 409 on concurrent edit (was 500), community
429/413 (was 500), update_profile 400 (was 500), and routes/user.go
reporting a resolver outage as 404 ProfileNotFound.
- Add 30 tests: mapper precedence, per-package code contracts, session
classification, and handle resolution.
Error codes are a client contract and are preserved, with one deliberate
exception: a wrapped communities.ErrHandleTaken now answers NameTaken
instead of AlreadyExists. The old `err ==` never matched because the service
wraps it, so the create path always returned AlreadyExists; errors.Is now
matches and yields the code the switch plainly intended. Clients keying on
AlreadyExists for a handle collision need updating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the p1 resilience gap in the backlog
(2026-07-22-no-db-pool-limits-no-http-timeouts).
The HTTP server was constructed with only Addr and Handler, so all four
net/http timeouts were zero — which means "no deadline". A client that
opened a connection and dribbled request headers held a goroutine and a
file descriptor indefinitely; enough of them exhaust the process without a
single complete request ever arriving. Separately, sql.Open was called with
no pool configuration at all, and database/sql defaults MaxOpenConns to
unlimited, so a traffic spike could open connections until PostgreSQL's
max_connections was exhausted — locking out psql and the cmd/ maintenance
tools along with the AppView.
Both are now bounded and env-tunable. Query time is bounded server-side via
statement_timeout injected into the DSN rather than per-query context
deadlines: lib/pq does send a CancelRequest on context cancellation, but
that path needs a second connection, races the query finishing, and does
nothing if the client dies outright. Schema migrations deliberately run on
a separate connection with statement_timeout stripped, since a CREATE INDEX
killed halfway is worse than a slow one.
main() was 1090 lines with 30 inline os.Getenv calls and no config struct.
Configuration now lives in internal/config, which applies defaults, rejects
malformed values, and enforces the requirements that differ between dev and
production — reporting every problem at once so a misconfigured deployment
is fixed in one pass instead of one restart per mistake. main() is now ~240
lines and returns an error rather than calling log.Fatal, which is what
makes the deferred cleanup reachable: os.Exit skips defers, so a fatal call
partway through startup abandoned the database pool and discarded the
buffered OpenTelemetry spans describing the failure.
Changes:
- Set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout (cmd/server/httpserver.go)
- Configure the pool: MaxOpenConns, MaxIdleConns, ConnMaxLifetime, ConnMaxIdleTime (cmd/server/database.go)
- Inject statement_timeout into the app DSN; strip it for migrations (internal/config/dsn.go)
- Add internal/config with Load/Validate and per-subsystem config structs
- Split cmd/server into main, wiring, routes, consumers, database, httpserver, jobs, health, pds
- Embed goose migrations (internal/db/migrations/embed.go), removing the
working-directory dependency and the Dockerfile's migrations COPY
- Drain background work concurrently with the listener rather than after it,
so a slow in-flight request cannot consume the whole shutdown budget and
leave Jetstream cursors unflushed; drain on the listener-failure path too,
and report shutdown failures through the exit code
- Bound each background job cycle and recover per cycle rather than per
goroutine, so neither a panic nor a hang can silently kill the job
permanently; run a cycle at startup instead of waiting out the first tick
- Resolve the OAuth session store once at boot and fail loudly if absent,
rather than turning the cleanup job into a silent hourly no-op
- Add a context timeout to the instance PDS login, which used
http.DefaultClient and could hang the boot forever
- Document the new HTTP_* and DB_* variables in the env examples
Production now fails closed on OAUTH_SEAL_SECRET (base64, 32 bytes),
CURSOR_SECRET (rejects the documented CHANGE_ME placeholder), JETSTREAM_FEEDS,
APPVIEW_PUBLIC_URL and PDS_URL (must not be loopback), a non-DID INSTANCE_DID,
SKIP_DID_WEB_VERIFICATION, and a zero value for any HTTP timeout. The current
docker-compose.prod.yml and .env.prod.example satisfy all of these; a live
.env.prod with a short CURSOR_SECRET or a malformed OAUTH_SEAL_SECRET will
refuse to boot.
Verified: make test-all green across all three stages, make fmt-check clean,
go vet clean, race detector clean on the changed packages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
internal/core/errors was dead code — zero importers — while six domain
packages each reimplemented the same ValidationError with a slightly
different message format. Because those were distinct Go types with
identical shapes, a handler could not ask "is this a validation failure?"
in one place: it had to call posts.IsValidationError, then
communities.IsValidationError, then aggregators.IsValidationError, and any
domain it forgot fell through to a 500.
The six packages now alias the shared type rather than redefining it:
type ValidationError = coreerrors.ValidationError
A Go type alias is the *same* type, not a similar one, so a single
errors.As at the API boundary matches validation failures from every
aliasing domain while each package keeps its own constructors and
predicates. Existing call sites compile unchanged.
Two latent bugs surfaced and are fixed:
- timeline.IsValidationError and discover.IsValidationError used a bare
type assertion rather than errors.As, so a validation error wrapped
anywhere in the stack with %w stopped being recognised and returned 500
instead of 400.
- posts.IsNotFound compared with == rather than errors.Is, which breaks
the moment any layer adds context.
Changes:
- Rewrite internal/core/errors as the canonical ValidationError,
NotFoundError, and ConflictError, with Is methods bridging the latter
two to shared sentinels
- Alias the shared types in posts, communities, aggregators,
communityFeeds, discover, and timeline
- Switch timeline/discover predicates to errors.As so they unwrap
- Switch posts.IsNotFound to errors.Is
- Replace a hand-rolled contains/anySubstring reimplementation of
strings.Contains in posts with the stdlib
- Add errors_test.go asserting the cross-domain property the whole change
exists for: reverting any alias to a local struct still compiles and
still passes each domain's own tests, so only this test catches it
Note for clients: ValidationError.Error() is now uniformly "field:
message". Previously posts returned "validation error (field): msg",
aggregators "validation error: field - msg", and timeline/discover the
bare message with no field prefix. These strings reach clients as the
XRPC error *message*; the error *code* is unchanged, so the lexicon
contract holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
make fmt-check, and therefore make lint, was already failing on main across
22 files before this branch of work. This applies `make fmt` and clears it.
Whitespace only: alignment of struct tags and trailing comments, one
over-long single-line function body split in list_test.go, and trailing
blank lines stripped at EOF. Verified by confirming every file here is
byte-identical to gofmt applied to its parent version, so the change is
semantics-preserving by construction. go build and go vet are clean and
make test-all still passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty PLCURL silently meant the production plc.directory, so a config
that merely omitted the field resolved identities against production. The
same hazard sat in identity.DefaultConfig(), which hardcoded production and
was used unmodified by ~30 integration test sites - so test runs issued real
lookups upstream for DIDs that only exist on the local PLC.
Nothing here could ever WRITE to a directory: DID registration is a PDS
operation and the dev PDS is pinned to the local PLC container, so no
production records were at risk. The silent default was still a footgun, and
an empty field should never be the way you select production.
Changes:
- NewOAuthClient errors when PLCURL is empty instead of falling through to
indigo's default directory; the directory override is now unconditional
and the startup warning became a structured log line
- identity.DefaultConfig honors PLC_DIRECTORY_URL, which the Makefile
already exports from .env.dev, pointing every test resolver at the local
PLC without touching ~30 call sites
- OAuth test helpers name their directory explicitly; unit tests use an
unroutable address so an unintended resolution fails loudly rather than
quietly reaching production
Also refreshes four tests that pinned superseded behavior:
- TestValidateActorProfile: actor.profile has had no required fields since
81ecb15, so "missing required field" is no longer a failure mode; now
asserts the constraints the schema does enforce
- TestGetCommunityFeed_BlobURLTransformation: expects embed.external#view
per the postView union, since a thumb rewritten to a URL no longer fits
the record schema
- TestOAuth_SessionFixationAttackPrevention: 62f9066 replaced raw-400
dead-ends with a first-party 302, so "not 302" asserted the mechanism
rather than the property; now asserts the redirect is first-party and
carries no session material
- TestUserCreationAndRetrieval/Resolve Handle to DID: resolves the real
bretton.dev, so it gets its own production-pinned READ-ONLY resolver
make test-all passes: 30 packages across unit, integration and E2E.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The RSS bridges wrote raw non-percent-encoded feed URLs into
embed.external.sources[].uri and richtext facet#link.uri. A literal "é" in a
slug resolves fine in a browser but fails the atproto `uri` format (printable
ASCII only after the scheme), so ~12% of live posts are invalid for any
third-party tool that resolves our lexicons and validates the firehose.
Fixed at both ends: the bridges encode at the source, and the AppView
normalizes on the write path. The AppView signs community post records into
the PDS itself, so it is the only layer that can guarantee a conforming record
regardless of client. Recoverable input is repaired rather than rejected, so
pasting a link containing an accented character keeps working; only genuinely
unusable input errors.
Encoding splits the URI with plain string operations instead of net/url.
url.Parse rejects several trivially recoverable inputs (a stray "%", an
interior tab, non-ASCII userinfo, an at:// URI whose DID reads as a port), and
round-tripping through url.URL.String() decodes reserved characters — turning
%2F into a path separator and silently repointing the link at a different
resource.
Changes:
- add internal/validation/uri.go: NormalizeURI/ValidURI with a typed sentinel
per failure cause
- add uri_sanitizer.py to both bridges, wired into create_external_embed and
RichTextBuilder.add_link
- pin cross-language agreement with a shared 46-case corpus at
internal/validation/testdata/uri_vectors.json, read by both the Go and Python
suites, so a one-sided change turns the other language's tests red
- punycode hosts via UTS-46 with DNS length verification (Go) and the IDNA2008
idna package (Python); the raw Punycode profile and the stdlib IDNA2003 codec
each produced a different domain than the user linked to
- refuse javascript:/data:/vbscript:/file:/mailto: rather than repairing them
into valid records; the check runs before the already-conforming fast path,
since those URIs are pure ASCII and would otherwise pass untouched
- enforce the lexicon's maxLength of 50 on embed.external.sources, and reject a
present-but-non-array sources value instead of silently skipping it
- keep the error chain intact: ValidationError gains Err/Unwrap and
NewValidationErrorFrom, comment wrapping uses %w:%w, so errors.Is reaches the
validation sentinels from the API boundary
- stop logging rejected URIs, which can carry signed-URL tokens; log
dropped/total source counts instead, escalating to ERROR when none survive
- declare idna as a direct dependency of both bridges
Facet link URIs are handled on the write path only. SanitizeFacets (firehose
ingest) is deliberately untouched and pinned by a test: existing federated
records are not being backfilled, and dropping their link facets on reindex
would be visible content loss.
Note: a post carrying a mailto: or javascript: link facet is now rejected
outright rather than degraded to unlinked text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
goat lex lint flagged 77 string fields with no maxLength across params,
outputs, and knownValues tokens. Cap them all (cursors 500, knownValues 64,
plus per-field caps for email/password/JWTs/api-keys/search queries).
Remaining large-string warnings are intentional (base64 image fields,
long-form content at the 10:1 grapheme ratio).
Add docs/LEXICON_PUBLISHING.md (publish/hold-back sets, decisions log,
workflow) and scripts/publish-lexicon-dns.sh (idempotent Cloudflare API
upsert of the 9 _lexicon TXT delegations; moderation deferred via flag).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prepare the social.coves.* lexicons for publication (com.atproto.lexicon.schema
records + _lexicon DNS). Once published, atproto evolution rules freeze the
schemas to additive-only changes, so this pass fixes everything breaking-shaped
now: style-guide violations from the original Opus-era schemas, and every place
a schema contradicted what the Go AppView actually reads and writes. All ~11k
live records on pds.coves.me + pds.bretton.dev verified unaffected via the new
validate-live sweep (only pre-existing bridge-URI failures remain). Reviewed by
multi-model /second-opinion; its confirmed findings are folded in here.
Schema correctness:
- Record account refs pinned to DIDs (post.community, ban.community — handles
are mutable/reassignable); live posts already store DIDs
- Removed literal "$type" properties from rules.json union members; removed
invalid additionalProperties (tagCounts -> unknown); dropped record-baked
governance defaults/minimums (policy lives app-side)
- knownValues normalized to kebab-case (moderator permissions, getPosts
filters); Go normalizes legacy snake_case filters for old clients
- 1:1 byte/grapheme caps fixed to ~10:1 across records AND procedures (embeds,
post tags, community name/description, comment content, editNote; post.update
content limits were inverted)
- Over-required fields relaxed pre-freeze (profile createdBy/hostedBy,
actor.profile createdAt, rule fields, memberView.reputation);
actor.block.createdAt now required; banView.indexedAt required
- name capped at 63 bytes everywhere to match the RFC 1035 check Go enforces
Schemas now tell the truth about the wire:
- actor.profile record/views renamed bio->description(+Facets), viewer
blockUri->blocking — matching writer, firehose consumer, and getProfile;
updateProfile input keeps `bio` (the actual wire name clients send)
- community.update input is communityDid; create/update/updateProfile declare
avatarBlob/bannerBlob as base64 strings + mime types (what handlers decode),
and update only advertises fields the handler supports
- New social.coves.community.post.defs holds the shared view types (moved out
of the post.get query file); comment/feed refs re-pointed; dangling
embed.record#view refs eliminated
- New embed external#view + post#view defs; blob_transform now rewrites $type
to the #view form when it URL-ifies thumbs or resolves quoted posts, so
served payloads validate against the type they declare
- vote.create output uri/cid optional (absent on toggle-off) instead of
required-but-empty-string; handler emits omitempty; pds CreateRecord now
errors on a 200 with empty uri/cid so {} can only mean toggle-off
- moderation ban subset cleaned (banView instead of refs to the record type,
unbanUser success boolean dropped, tribunalCase as strongRef); governance
trio (ruleProposal/tribunalVote/vote) deliberately untouched and held back
from publishing until tribunal design lands
Tooling and enforcement:
- New cmd/validate-live: sweeps every social.coves.* record on a live PDS via
public XRPC and validates against local schemas; exits 2 on incomplete
sweeps, guards non-advancing cursors, sorted deterministic output
- cmd/validate-lexicon cross-ref list updated for the new defs
- update_profile handler validates displayName/bio by graphemes+bytes (uniseg)
per the published contract; fixtures and tests updated
Client coordination required before next appview deploy (tracked in memory):
transformed embeds now declare #view $types; vote toggle-off omits uri/cid.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds base-uri 'self', object-src 'none', and frame-ancestors 'none' to
the coves.social CSP (frame-ancestors matches the existing site-wide
X-Frame-Options DENY).
Also documents the carve-out needed when the SvelteKit frontend joins
this stack: this static header REPLACES the app's per-request nonce'd
CSP, and its script-src 'unsafe-inline' would silently downgrade the
frontend's script-src hardening — flagged in the July 2026 frontend
security review.
Deploy note: single-file bind mount pins the inode — use
`docker compose up -d --force-recreate caddy`, not `caddy reload`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend social.coves.richtext.facet with block-level features so bridged
Lemmy/fediverse content (nested quotes, headers, code) is representable,
and add the validation/sanitization layer facets previously lacked
entirely. Reviewed via multi-model /second-opinion; all Critical and
Important findings fixed.
Lexicon (wire spec for the three renderer implementations):
- New open-union features: blockquote (optional level 1-6, nested quotes
= disjoint ranges with increasing level, never containment), heading
(required level 1-6, single line), code, codeBlock (optional language)
- Conventions in descriptions: block ranges span whole lines excluding
trailing newline; readers extend malformed ranges to line boundaries;
unknown feature $types MUST degrade to plaintext; writers strip
markdown markers and clamp nesting deeper than 6
- Caps: facets maxLength 200 (was unbounded), features-per-facet 20
- Procedure lexicons (post/comment create+update) synced with record
schemas; comment procedures declare the new InvalidFacets error
New internal/core/richtext package:
- ValidateFacets (reject) at API writes, SanitizeFacets (drop+log) at
firehose ingest -- federated records can't be bounced to their author,
and clients must never receive ranges slicing outside the content
- Structural checks: byte-range sanity, integral offsets across all four
JSON number encodings with uniform int32 bounds, features shape
- Known-feature attribute enforcement (heading.level required/range,
blockquote.level range, codeBlock.language <=40, spoiler.reason <=128);
unknown $types pass untouched to keep the union open
Write paths:
- posts.validateCreateRequest validates facets vs untrimmed content
- comments Create/Update validate vs trimmed content and REJECT facets
paired with untrimmed content -- leading whitespace would silently
shift every annotation in the signed PDS record
- New ErrInvalidFacets sentinel + HTTP 400 InvalidFacets mapping
Ingest paths (all three consumers now sanitize before indexing):
- post_consumer: sanitizedPostFacets on create + update
- comment_consumer: serializeOptionalFields sanitizes (non-mutating),
covering create/update/resurrect/re-create
- community_consumer: descriptionFacets were indexed with NO validation
(same attack class); now sanitized on create + update, stale facets
cleared when a record arrives without surviving ones, marshal
failures logged instead of silently kept
Tests (~60 new cases): full range/attribute/cap matrix incl. all JSON
number encodings, consumer wiring seams (nil content, drop+serialize,
non-mutation), trim-mismatch guard, UpdateComment rejection, indigo
schema validation of new features, executable byte-offset conventions
doc, validate-lexicon fixture. go build/vet + full unit suite clean.
Rollout note: inert until producers emit block facets. Client renderers
(coves-frontend, coves-mobile) must ship before tidepool starts
stripping markdown markers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-model review follow-up to cc98f26. The AS-error branch honored any
?error= with no state validation — indigo's ProcessCallback deliberately
checks state BEFORE error, and the branch bypassed that: a forged
top-level GET to /oauth/callback?error=x&error_description=<text> rode
the victim's SameSite=Lax cookies, killed their in-flight login via
clearMobileCookies, and 302'd attacker-chosen text into the app's
trusted error UI. Separately, the server-stored redirect URI was only
fetched when the mobile cookie existed, so the cookie-less mobile user
it was meant to rescue could never benefit, and ProcessCallback
failures still dead-ended on the raw 400 page (with %v of the internal
error) that cc98f26 set out to eliminate.
Changes:
- Fetch mobile OAuth data by state unconditionally; a state with no
pending oauth_requests row redirects to /?oauth_error=invalid_request
WITHOUT clearing cookies — forged callbacks can no longer kill an
in-flight login or reach the app
- Mobile error redirects now use the server-stored redirect URI as the
sole qualification and target (still allowlist-checked); the cookie is
a secondary binding check, no longer a prerequisite
- Clamp outgoing error codes to the RFC 6749/OIDC set; unknown codes
become server_error and drop the description
- ProcessCallback failures redirect to /?oauth_error=server_error
instead of a raw 400 page leaking internal error text
- Binding cookies fail closed on a partial pair, matching the success
path; web error redirects honor PublicURL in production
- Log previously-silent failure branches (undecodable cookie, store
lookup errors) and tag web fallbacks with had_mobile_cookie so
degraded-mobile flows are greppable
- Correct doc comments that claimed guarantees the code didn't enforce
- Add handlers_error_redirect_test.go (33 subtests): forged callbacks,
cookie-less mobile rescue, clamping, partial binding pairs, and both
ProcessCallback-failure variants at handler level
Known follow-ups (out of scope here): post-exchange failure branches
(nil session, DID lookup, handle mismatch, seal failure) still serve
static error pages instead of returning to the app; flow-start handlers
still echo %v of internal errors; custom-scheme deep links remain
hijackable by design until Universal/App Links land.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Multi-model review follow-up to 0feddc1. The MIME guard only rejected
empty strings while its own error text promised concrete types, the
validation error was untyped (surfaced as a 500 via the caller's
errors.Is switch), and result.Blob was dereferenced unchecked — a 2xx
with a missing blob panics, and a blob missing its ref yields a
zero-value CID whose String() is the garbage "b", silently storing a
broken avatar past the handler's nil check.
Changes:
- Reject wildcard MIME types ("*/*", "image/*") at the client boundary,
not just empty ones, so the confusing remote ScopeMissingError can't
recur from a future caller
- Wrap ErrBadRequest in the validation error so callers classify it as
a 400-shaped failure
- Validate the uploadBlob response: nil Blob or undefined Ref returns a
classified error instead of panicking / storing a zero CID
- Add TestClient_UploadBlob (httptest, 9 cases): outbound Content-Type
equals the caller's MIME type, empty/wildcard rejection with zero HTTP
requests, 403 -> ErrForbidden, malformed-2xx handling
Deliberately NOT added: an image-MIME allowlist inside the client — the
PDS client is generic blob transport; content policy stays in the
handlers, which already enforce it on both call paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author avatars/display names never rendered anywhere except the standalone
profile endpoint, and ~870 pre-relay-switchover users had permanently bare
profiles because their social.coves.actor.profile firehose event was dropped
while bsky.network throttled the bridge PDSs (profile records are written once
at rkey "self" — a missed create event never replays).
Gap 1 — views never selected author profile columns:
- postViewSelectColumns now includes u.display_name/u.avatar_cid/u.pds_url and
is the single source of truth for every PostView query: the six hand-copied
feed SELECT blocks collapse into feedPostSelectClause, and scanFeedPost
delegates to the shared scanPostView (variadic extraDest carries hot_rank)
- comment views hydrate author display name/avatar from the already
batch-loaded user rows (was hardcoded nil); thread-root posts too
- side effects of the unification: feeds now populate editedAt (was scanned
and dropped), and malformed embed JSON omits the record key instead of
serializing "embed": null
Gap 2 — no reconciliation for missed profile events:
- users.FetchProfileRecord fetches actor.profile/self from the user's own PDS
via com.atproto.repo.getRecord (1 MiB body cap, quoted error bodies, strict
RecordNotFound discrimination — bare 404s from non-PDS hosts are errors)
- IndexUser backfills users indexed with a completely empty profile: sync
emptiness check, detached fetch goroutine (WithoutCancel + 10s timeout) so
OAuth callbacks and firehose consumers never block, pre-write re-check so a
concurrent firehose event wins; opt-in via WithProfileBackfill, wired in prod
- cmd/backfill-profiles: re-runnable reconciliation job for existing bare
users (dry-run, bounded concurrency, atomic stats, exit 1 on failures)
Hardening from multi-model review: fetched displayName/bio rune-truncated to
the DB CHECK limits (hostile PDS could otherwise wedge backfill in a permanent
fetch-fail loop), implausible blob CIDs rejected, HydrateImageURL no longer
warn-floods on empty CIDs, social.coves.actor.profile constant consolidated to
users.ProfileCollection.
Tests: 20+ new unit tests (fetch classification, truncation, async backfill
semantics, comment/post author hydration) plus an integration test pinning
author hydration through GetViewsByURIs, GetByAuthor, and community feeds,
including the author-DID-in-URL and bare-author-stays-bare cases.
Also gitignores .claude/agent-memory/ (per-machine review-agent state).
Deploy note: run cmd/backfill-profiles once against prod (with -dry-run first)
to heal existing bare users.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While unset, dev mode generates a random seal secret on every appview
boot, so each restart invalidates all sealed mobile tokens and kills
every mobile session (this bit us repeatedly). Set a persistent
localhost-only value and document why it must stay set.
Also add a header note making explicit that .env.dev is intentionally
tracked: every credential in it is localhost-only, and production
secrets belong in the gitignored .env.prod.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancelling sign-in or denying consent on the PDS authorization page
redirects back to /oauth/callback with ?error=access_denied — which the
handler fed to ProcessCallback and answered with a raw 400 text page at
127.0.0.1:8081, stranding mobile users outside the app with no way back.
Handle authorization-server errors before the code exchange: mobile flows
302 to the app's redirect URI with the error params
(social.coves://callback?error=access_denied&error_description=...) so
the app regains control; web flows go back to / with ?oauth_error=.
Unexpected ProcessCallback failures also redirect mobile flows (generic
server_error code — details stay in the server log).
The redirect target is never attacker-controlled: the server-side stored
redirect URI (keyed by OAuth state) is preferred over the cookie, the
cookie CSRF binding must validate when present, and the final URI must
pass the same allowlist as the success path. No tokens travel on this
path — only the error code the authorization server already showed.
Verified via Playwright against the dev stack: Cancel on the sign-in page
and Deny on the consent page both now 302 to
social.coves://callback?error=access_denied&error_description=The+user+rejected+the+request
with the mobile cookies cleared, and the authorize-and-accept happy path
still completes with a sealed token deep link.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Indigo's generated RepoUploadBlob helper hardcodes Content-Type: */* (the
lexicon's accepted encoding). A PDS enforcing the granular blob:*/* OAuth
scope matches the granted accept patterns against the request's
Content-Type and requires a concrete MIME type — a wildcard never matches
(oauth-scopes' matchesAnyAccept rejects any mime containing '*'), so every
upload through the generated helper failed with ScopeMissingError
"Missing required scope \"blob:*/*\"" even on fresh sessions whose grant
carried blob:*/*. The error's quoted scope is derived from the attempted
content type, which is why it reads exactly like the granted scope.
Call LexDo directly with the caller-supplied mimeType (which handlers
already validate as a concrete image type) instead of the generated
helper, and reject empty mime types at the client boundary.
Verified against the dev stack: fresh OAuth login as mari, then
social.coves.actor.updateProfile with a PNG avatar — PDS log shows
content-type: image/png -> 200 where it previously showed */* -> 403
ScopeMissingError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every PDS error switch lumped pds.ErrForbidden (403) in with
ErrUnauthorized (401) and answered HTTP 401 with AuthExpired/AuthRequired
— the exact signal clients use to destroy the session. So an avatar
upload rejected for a missing blob:*/* scope signed the user out of a
perfectly valid session. The scope gap itself hits sessions whose OAuth
grant predates c65da94 (which added blob:*/* and the granular repo:
scopes): token refresh preserves the original grant's scopes, so those
sessions mint fresh access tokens without blob:*/* until the user fully
re-authenticates.
New contract: 401 → AuthExpired/AuthRequired (session dead,
re-authenticate); 403 → PermissionDenied with HTTP 403 and a message
explaining that signing out and back in re-grants the permission. The
updateProfile lexicon defines no error names, so PermissionDenied is a
compatible addition.
Changes:
- Split ErrForbidden from ErrUnauthorized in all five mapping sites:
update_profile.go (avatar upload, banner upload, putRecord),
userblock/errors.go, community/errors.go
- Update PutRecordForbidden and BannerUploadForbidden tests to assert
the new 403/PermissionDenied mapping
- Add AvatarUploadForbidden test (path previously uncovered)
Frontend follow-up (coves-frontend, coves-mobile): sign out only on 401;
surface 403 PermissionDenied as a message with a reconnect option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The social.coves.community.get lexicon promises viewer state when the
caller is authenticated, but the endpoint never delivered it: the route
was registered without OptionalAuth middleware (so the caller's DID was
never resolved) and the handler never invoked the viewer-state helper.
Subscribing to a community then reloading its page reverted the button
to "Subscribe", making it impossible to unsubscribe from the community
page. community.list already did this correctly; this copies its exact
pattern.
Changes:
- Add OptionalAuth middleware to the community.get route so the
authenticated caller's DID reaches the handler
- Pass the communities repository into GetHandler and populate
viewer.subscribed via common.PopulateCommunityViewerState before
serializing (ToCommunityViewDetailed already carried the field)
- Add integration test covering subscribed=true, subscribed=false, and
viewer omitted for unauthenticated requests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jetstream emits identity events without a handle when an identity's
handle is invalidated or tombstoned — valid events, and frequent at
network scale. The users consumer rejected them as permanent failures,
and since the dead-letter pipeline shipped, every one became a junk DLQ
row (~tens of thousands/day observed on prod within minutes of the
multi-feed deploy). We index only known users and have no handle-invalid
state to record, so there is nothing to apply: skip cleanly. A missing
DID remains a permanent structural rejection.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The AppView now consumes N Jetstream feeds carrying the same repos: the
public bsky.network Jetstream plus our self-hosted relay+Jetstream pair
(tidepool stack) that crawls tdpl.io + pds.coves.me with no per-host
account quotas. Feeds are internally ordered but skewed by hours, so a
lagging feed replays a repo's history AFTER newer events were applied —
resurrecting deleted comments/votes, regressing edits, re-subscribing
unsubscribed users. The existing time_us recency guards cannot catch
this: each feed stamps its own emission time, so a stale copy arrives
with a NEWER timestamp than the state it would clobber.
The keystone is rev-gating: every commit event carries rev, the repo's
monotonic TID. jetstream_record_revs (migration 033, COLLATE "C") stores
the last APPLIED rev per record URI; create/update/delete apply only if
strictly newer. Equal rev = duplicate replay (no-op, subsumes existing
duplicate handling); the gate row survives hard deletes as the tombstone
that rejects stale creates. One rule, no heuristics, no CID comparisons.
Changes:
- rev_gate.go: conditional-upsert gate primitives + RevGate + applyGated
(transactional claim held across apply; same-URI handlers serialize on
the gate row lock, apply failure rolls back un-advanced for redrive)
- posts/comments/votes: gate as first statement of existing transactions;
delete paths restructured gate-first so the tombstone commits atomically
with the deletion (closes the not-found-delete vs concurrent-create race)
- users/communities/aggregators: injected RevGate; deletes tombstone even
for never-indexed records; comments re-apply genuinely-newer same-rkey
re-creates on active rows; SubscribeWithCount conflict path updates
record_uri/cid last-write-wins (cross-rkey redriven-delete safety)
- feeds.go: JETSTREAM_FEEDS="bsky=…;self=…" replaces six per-consumer URL
env vars (now fatal at boot with migration hint); per-consumer collection
filters derived in code via WantedCollections (unknown name = fatal, no
more filterless whole-firehose subscriptions); "bsky" feed keeps legacy
consumer names so live cursors carry over; @self consumers live-tail
- fail-closed boot checks: multi-feed refuses ungated consumers
(RevGated()), missing JETSTREAM_FEEDS fatal outside dev, warning when
no primary feed key is configured
- fixes latent drift: community.block was never in the subscribed
collections; users consumer subscribed to the entire firehose in prod
- tests: adversarial cross-feed interleavings (zombie create, stale update
clobber, phantom vote, delete-before-create tombstones for votes AND
posts, subscription zombie, profile stale-replay, gate semantics) +
feeds parsing suite; Makefile test target runs packages sequentially
(-p 1) so the integration suite's unscoped table wipes can't race other
packages on the shared test DB
Known limitations (documented in code): identity events carry no rev so
handle changes are not cross-feed ordered; posts/votes active-row same-
rkey re-creates after a dead-lettered delete keep the old content.
Deploy notes: migration 033 auto-runs at boot. Prod compose sets
JETSTREAM_FEEDS with self=ws://tidepool-prod-jetstream:8080 (relay +
Jetstream deployed 2026-07-17). Expect "rev-gate: skipped stale" log
lines for lagging bsky copies — that is the system working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The five Jetstream consumers (posts, votes, comments, communities,
aggregators) plus the user consumer previously resumed at the live tail
after every restart, deploy, crash, or reconnect — events during the gap
were permanently lost from the AppView — and handler failures were
log-and-dropped. Consumers also ran on a never-cancelled
context.Background(), so SIGTERM killed them mid-transaction, and there
was zero visibility into indexing health.
One shared jetstream.Connector now replaces the five byte-identical
per-consumer connector files and the user consumer's inline connection
loop, with the invariant: the cursor advances past an event only if the
handler succeeded OR the event is durably captured in the dead letter
queue; if the dead-letter write itself fails, the connection tears down
without advancing so the event replays. Verified end-to-end against live
infrastructure: kill/restart resumes all six consumers from the exact
persisted cursor.
Changes:
- Cursor persistence (jetstream_cursors, migration 032): consumers dial
with &cursor= rewound 5s per Jetstream gapless-playback guidance;
monotonic at three layers (in-memory guard, flush check, SQL WHERE);
flushed every 5s and on shutdown. All handlers verified/made
replay-idempotent, with duplicate-delivery tests for the
count-mutating vote/comment paths.
- Retry then dead-letter (jetstream_dead_letters): 3 in-line retries
with backoff, then the raw frame is captured (BYTEA, so byte-corrupt
frames can't wedge a consumer) with a dedup unique index so rewind
replays can't mint duplicate rows with fresh retry budgets.
- Error taxonomy: permanent rejections (validation/security failures)
wrap jetstream.ErrPermanentEvent — no retries, dead-lettered born
exhausted; ordering-dependent failures (post-before-community) stay
transient so the redriver heals them.
- DeadLetterRedriver: replays transient dead letters every 5 minutes
(boot pass included, drains full backlog per tick, max 10 attempts,
shutdown doesn't burn attempts).
- Stale-redrive recency guards: post/comment/profile updates carry a
time_us watermark so a redriven old edit can never overwrite a newer
one (posts guard is atomic in the UPDATE WHERE clause).
- Graceful drain: consumers run on a cancellable context with a
WaitGroup; SIGTERM unblocks read loops, abandons in-flight events
without advancing the cursor, and flushes cursors before exit.
- GET /health/consumers: per-consumer connection state, cursor age,
processed/dead-lettered counts, DLQ backlog; 503 when stalled,
degraded when the backlog count is unavailable; /health stays a pure
liveness probe so a Jetstream outage can't restart-loop the AppView.
- Hardening: 16MiB WebSocket read limit, double-Start guard, ping-failure
fast teardown, consumer-name constants keying persisted state.
- Tests: connector suite against a real WebSocket server (cursor resume,
rewind redial, DLQ-write-failure freeze, shutdown flush), Postgres
state-store suite (monotonicity, dedup, binary payloads), taxonomy
tests per consumer, recency-guard tests, health-handler tests.
- Fixed TestVoteE2E_JetstreamIndexing subscribe-after-write flake by
subscribing with a pre-write cursor so Jetstream replays the event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since federating with Lemmy, bridged posts with large-population vote counts
(e.g. 782 likes, 2 days old) dominated discover/hot for up to a week while
fresh organic Coves posts with 0 votes sank. The linear Reddit-style numerator
was the cause: vote counts scale with instance population, so cross-instance
comparison on a linear scale is meaningless.
New formula (was: (score + 1) / (age_hours + 2)^1.5):
(SIGN(score) * LN(ABS(score) + 1) + 1) / (GREATEST(age_hours, 0) + 2)^1.5
Tuning (pinned by integration test):
- Fresh 0-vote post outranks a day-old 782-vote blowout (0.354 vs 0.058)
- Day-old 782-vote post still outranks a 6h-old 0-vote post (0.058 vs 0.044)
- A blowout dominates brand-new posts only for its first ~6 hours
- Negative-score posts rank below all non-negative posts of similar age
Changes:
- Centralize the hot-rank SQL in hotRankSQL() (feed_repo_base.go); the live
ORDER BY and the cursor pagination comparison now share one builder and can
no longer drift apart (previously 4 hand-synced copies, already divergent)
- Collapse the three identical per-repo sortClauses maps into one shared
whitelist; newFeedRepoBase() drops the per-repo formula/clause params
- Clamp age at 0 in the formula: future-dated created_at (hostile or
clock-skewed federated records) ranks like a brand-new post instead of
erroring the query (negative POWER base 500'd the whole hot feed) or
gaining a rank boost
- SECURITY: clamp future createdAt to now at Jetstream ingestion
(post_consumer.go) - also protects the "new" sort from future-date pinning
- Remove dead nullStringPtr; fix stale hot-rank comments and docs
- Tests: pin the ranking tuning, hot pagination to exhaustion across the
positive/negative rank boundary (live/cursor formula sync), future-dated
post handling; createTestPost now derives vote counts for negative scores
Reviewed via /validate and /second-opinion (7-reviewer fan-out); all findings
addressed except documented pre-existing tech debt (comment hot sort remains
linear by design - same-post voter population).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dns shortcut builds a single Let's Encrypt issuer, and LE's
50-new-certs/week bucket for tdpl.io is already exhausted by the
on-demand per-handle churn — every wildcard obtain 429s with no
fallback (Caddy >= 2.10 no longer defaults to ZeroSSL). Configure the
issuers explicitly: LE first, ZeroSSL second. ZeroSSL reuses the ACME
account the old image's default fallback registered in storage, so no
EAB is needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Since Caddy 2.10, a wildcard subject that covers another managed subject
silently wins: *.*.tdpl.io from the on-demand catch-all block covers
every *.<instance>.tdpl.io, so caddytls skipped issuance of all 64
instance wildcards at startup — no log line, no cert, no error
(managingWildcardFor in modules/caddytls/tls.go). force_automate puts
the block's names into the tls.certificates.automate loader, the
documented bypass for that preference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bridged handles are two labels below the apex (name.instance.tdpl.io) and
a wildcard cert covers only one level, so Caddy was issuing a cert per
handle on demand at handshake time — handshake-blocking, and every handle
draws from Let's Encrypt's 50-new-certs/week bucket for tdpl.io. At ~219
bridged repos the bucket exhausted and identity resolvers timed out,
recording handle.invalid for ~200 author identities.
One DNS-01 wildcard per bridged instance (*.lemmy-world.tdpl.io, 64
instances from production bridged_actors) covers every user of that
instance with a single pre-issued cert. The on-demand block stays as the
catch-all for instances not yet listed. DNS-01 needs API write access to
the tdpl.io zone: new CLOUDFLARE_API_TOKEN_TDPL env (Zone:DNS:Edit,
tdpl.io only), separate from the coves.social token so either rotates
independently.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BridgeTrust gates bridgedStats on communities.pds_url, but firehose-
indexed communities (Tidepool bridge communities above all) were created
with it empty — the gate default-denied every bridged vote count with
'not a trusted bridge PDS'. The consumer already resolves the repo's
identity for the handle; now it stores the identity's PDS host on create
AND on update (COALESCE-guarded in Update() so callers without the value
keep the stored one — this also heals rows indexed before the fix on
their next profile event).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A community.profile omitting visibility is valid (the lexicon declares
default 'public'), but the consumer passed the empty string through to
the INSERT, tripping communities_visibility_check — every Tidepool
community failed indexing at the very last step, after hostedBy
verification had already passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- global email: provisions ZeroSSL as fallback CA (Caddy 2.8+) so an LE
rate-limit hit degrades instead of hard-failing on-demand handshakes
- drop the active health check on the single tidepool upstream: one
failed probe would 503 the whole site with nothing to fail over to
- cap inbox request bodies at 1MB (AP activities are small JSON; media
is fetched outbound, never uploaded)
- drop redundant header_up lines (Caddy passes Host and sets
X-Forwarded-For/-Proto itself)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apex block proxies AP + com.atproto.sync.* to the tidepool container
(joins coves-prod-network from the /opt/tidepool stack). Bridged-handle
subdomains sit two labels below the apex, so a single wildcard cert
cannot cover them: certs are issued on demand per hostname, gated by
Tidepool's /.well-known/tidepool-tls-ask endpoint so wildcard-DNS probes
cannot burn ACME quota. Bridge PDS host examples now use tdpl.io.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BigSky preserves event order within a repo but not across repos, so a
bridge-authored post (in a community repo) can reach the AppView before
the author's actor.profile (in their own repo). Previously such posts
were rejected ("author not found") and bridged profiles for
never-before-seen identities were silently skipped, leaving virtual
bridge users unindexable. This wires a shared, provenance-gated
discovery path across the user and post consumers so bridge identities
enter the AppView safely without opening the door to indexing every
profile on the network.
Changes:
- bridge trust shared across consumers: TRUSTED_BRIDGE_PDS_HOSTS parsed
once in main.go and passed to the user consumer (WithUserBridgeTrust)
and post consumer (WithPostBridgeTrust); default-deny when unset
- user consumer: profile create/update for an unknown DID now resolves
identity and indexes only when the resolved DID matches and its PDS is
a trusted bridge; native unknowns still ignored (avoid-indexing-the-
world policy preserved); DB errors now propagate instead of updates
being dropped
- post consumer: on ErrUserNotFound, resolve the author and minimally
IndexUser only when hosted by a trusted bridge (WithPostIdentityResolver);
untrusted/native authors still rejected
- post consumer now indexes CREATE/UPDATE/DELETE (log line corrected)
- config: TRUSTED_BRIDGE_PDS_HOSTS documented in .env.prod.example and
wired through docker-compose.prod.yml
- tests: cross-repo ordering (post-before-profile) indexes trusted
bridge author; unknown profile hosted by trusted bridge is indexed;
test doubles updated (mock resolver identities map, IndexUser records)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bridged (Lemmy-origin) vote counts now arrive as an optional
bridgedStats {upvotes, downvotes, asOf} field on post/comment records
emitted by the tidepool bridge. Coves folds them into displayed counts
and score so bridged content ranks natively, gated on provenance so
only bridge-managed repos may assert aggregate counts.
Changes:
- lexicons: social.coves.community.post/comment gain #bridgedStats
(upvotes, downvotes, asOf — all required within the optional field)
- migration 031: bridged_upvote_count / bridged_downvote_count
(CHECK >= 0) + bridged_stats_as_of on posts and comments
- provenance gate (jetstream/bridge_trust.go): bridgedStats applied
only when the repo's stored pds_url matches TRUSTED_BRIDGE_PDS_HOSTS
(default-deny; content still indexes with the field ignored
otherwise); input hygiene regardless — negatives rejected, 1M
magnitude cap mirroring the bridge's MaxSeededCount
- post consumer: new UPDATE handler (updates were previously silently
dropped — bridged post edits never re-indexed): create-parity
security validation, community/author reassignment rejected,
soft-delete skip, atomic newer-or-equal asOf guard in SQL, edited_at
bumped only on real content changes, RowsAffected checked
- comment consumer: same gate + guard on its update path; soft-deleted
comments skipped; resurrection recomputes score from surviving
native counts
- score is inclusive — (native+bridged up) − (native+bridged down) —
at every write site; flip-decrement now uses the same clamped
arithmetic as the count columns; hot/top sort expressions, cursors,
and indexes untouched
- read paths fold bridged counts into displayed up/down everywhere
(incl. comment GetByURI; the consumer's guard reads raw columns)
- log hygiene: infra errors no longer logged as security rejections;
false "Jetstream will replay" comments corrected (connectors are
log-and-drop, no cursor)
Deploy note: TRUSTED_BRIDGE_PDS_HOSTS must name the bridge's PDS host,
or bridgedStats is ignored everywhere (safe default).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a parentRkey parameter to social.coves.community.comment.getComments so
clients can fetch a single comment's subtree instead of the whole thread. When
parentRkey is provided the response contains exactly one top-level
threadViewComment — the comment with that rkey within the post — with its
descendants nested beneath it. depth is relative to that comment, and
limit/cursor paginate its direct replies through the same hot-rank listing used
for top-level comments. This backs the frontend comment permalink page
(/c/<community>/post/<rkey>/comment/<crkey>) and lets "load more replies" fetch
just the relevant subtree.
Changes:
- getComments: resolve parentRkey via an indexed root_uri + rkey lookup
(GetByRootAndRkey). Unknown rkeys return 404 ParentNotFound; malformed rkeys
are rejected at the handler and re-validated in the service. rkeys are TIDs,
so cross-commenter collisions within one post are astronomically unlikely; if
one occurs the earliest-indexed comment wins. Deleted parents return their
tombstone placeholder with children intact.
- Author-block filtering: GetByRootAndRkey and the children listing take a
viewerDID; a permalink to a comment whose author the viewer has blocked
returns ParentNotFound, matching the thread and feed read paths.
- Error propagation: buildThreadViews returns an error instead of logging and
returning a silently truncated tree, so a transient batch-load failure
surfaces as a real request error rather than "no replies" with a misleading
HasMore=false.
- Cursor errors: new ErrInvalidCursor sentinel; all repository cursor parse
failures wrap it so the handler maps them to HTTP 400 InvalidRequest with a
fixed message instead of leaking internal detail. depth=0 combined with a
cursor (which can never be honored) is rejected.
- Lexicon: add parentRkey (format record-key) to getComments.
- Tests: unit coverage for subtree return, depth semantics, ParentNotFound,
children pagination, blocked-author filtering, repo-error-not-masked-as-404,
viewer DID plumbing, depth0+cursor rejection, and cursor/batch error
propagation; integration coverage for deleted-parent placeholder, viewer
block, rkey-collision determinism, and RootNotFound precedence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post.create handler accepted req.Embed as an untyped map and wrote
it to the PDS verbatim. A malformed embed (e.g. a bare {"uri":"..."}
with no $type discriminator) was silently persisted as an unrenderable
record — readers key off $type and drop it, so the user's link vanished
with no error anywhere in the pipeline.
Add validateEmbed(), called early in validateCreateRequest, which
enforces the post.create lexicon union: a recognized $type plus the
required nested field(s) for each member (external.uri; non-empty
images[] each with an image blob object; video blob; post.{uri,cid}).
Malformed embeds now return a ValidationError → HTTP 400 InvalidRequest
at the API boundary instead of corrupting the repository. Unknown and
output-only #view types are rejected fail-closed.
Changes:
- Add internal/core/posts/embed_validation.go with validateEmbed()
- Wire validateEmbed() into validateCreateRequest (service.go)
- Add embed_validation_test.go: 28 table cases covering every union
member's happy path and failure modes (incl. the bare-{uri} bug)
- Add TestPostHandler_EmbedValidation integration test proving the
validation is wired into the real handler->service create path
- Make TestPostHandler_ThumbValidation idempotent: add a scoped
pre-run cleanup so it no longer depends on collateral deletes from
earlier-sorted tests when run in isolation against the persistent DB
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-on hardening for social.coves.community.post.get: tighten the
batch-fetch SQL, make the union-member guard fail loud on assembly bugs,
and add the missing handler/integration coverage (postView, notFoundPost,
and the viewer author-block blockedPost path against live infra).
Changes:
- post_repo: replace the interpolated IN-list in GetViewsByURIs with a
static `= ANY($1)` (pq.Array) query so the SQL is constant, fully
parameterized, and yields one cached plan regardless of batch size
- post_repo: extract postViewSelectColumns as the single source of truth
for the SELECT list shared by GetViewsByURIs and GetByAuthor, so the
two queries cannot drift out of sync with scanPostView's positional Scan
- posts.Member: treat "more than one member set" as invalid (count != 1),
surfacing a mis-assembled result as an internal error instead of
silently picking one and emitting an ambiguous union entry
- routes: fail fast at registration if oauthMiddleware is nil (it backs
OptionalAuth for the public post.get endpoint) instead of panicking on
the first request
- get_test: add handler tests for over-length URI (400, service not
called), service-error->status mapping (validation 400 / internal 500),
viewer-DID passthrough, and duplicate-URI handling
- service_get_posts_test: add multiple-member-set cases for Member
- user_journey_e2e: wire the real userblocks BlockChecker into the journey
post service (as cmd/server/main.go does), then add Part 3b (live
postView + notFoundPost, request order) and Part 3c (a viewer records an
author-block and post.get returns a blockedPost with no content leak),
giving live-infra coverage of all three post.get union members
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the critical review item: GetPostsRequest.ViewerDID was threaded
through but unused, so the get-by-URI (permalink/cold-load) path returned posts
the feed/timeline paths would hide for a viewer who blocked the author. The
lexicon already defines #blockedPost in the output union, so this wires it up
rather than dropping the field.
- posts.BlockChecker interface (satisfied by userblocks repo's AreBlocked),
injected via new WithBlockChecker functional option (no churn to ~20 existing
NewPostService callers)
- GetPosts now batches a block lookup over unique author DIDs when a viewer is
present and rewrites blocked authors' posts to BlockedPost (blockedBy:author);
fails closed on lookup error so unverified content is never surfaced
- author-only for now: community blocks aren't enforced on any read path, and
moderator removals already surface as notFoundPost (soft-deleted rows absent)
Coupled type-design/union fix:
- PostResult gains a Blocked variant + exhaustive Member() accessor; the handler
now 500s on an unset result instead of emitting a null union member (200 OK)
- result constructors set the const discriminators (notFound/blocked == true)
Tests: blocked/unblocked/not-found ordering, author-DID dedup, skip when no
viewer or no checker, fail-closed on error, Member() exhaustiveness, and handler
tests for the blockedPost member + invalid-result 500.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Batch-fetch post views by AT-URI for feed hydration and permalink/cold-load
rendering. Returns posts in request order; missing or soft-deleted posts come
back as notFoundPost markers.
- posts.Service.GetPosts + Repository.GetViewsByURIs (deduped batch fetch,
canonical DID-URI validation, request-order assembly)
- GET handler at /xrpc/social.coves.community.post.get with per-URI length
caps and the shared batch-size limit
- viewer vote-state enrichment, blob-ref and embed transforms on found posts
Baseline reviewed via /second-opinion; review fixes follow on this branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integration E2E suite intermittently failed under `make test-all` (yet passed
in isolation) whenever a test subscribed to the cursorless Jetstream firehose
AFTER issuing its PDS write. A cursorless subscription only streams commits
emitted after the socket is dialed, so under full-suite CPU load the commit was
relayed before the subscriber connected and was lost, hanging the test until its
deadline. This is a test-only race — the production AppView consumer is a
long-lived connection that is always listening first.
Two fix patterns, applied per case:
- Cursor replay (helpers.go: jetstreamCursorNow + withJetstreamCursor): capture a
time_us cursor immediately before the write, then subscribe with it so Jetstream
replays past the race. Used where the entity DID is generated by the write
itself (community_e2e Part 2, user_journey Parts 2 & 3, post_e2e Part 2). Safe
by construction: Jetstream stamps time_us at ingest (after the write), so the
cursor sits below our event and above any earlier one; the shared host clock
means no skew margin is needed.
- Subscribe-before-write (user_profile_avatar ReplaceAvatar): replaced the dead
goroutine + dial-after-write helper with subscribeForProfileEvent, which dials,
signals readiness, then returns a wait closure invoked after the write. Window
widened 15s -> 30s to match siblings.
Also swept all remaining modulo-based test handles (% 10000 / UnixNano() % N) onto
the collision-free uniqueTestID() helper across user_profile_avatar, user_journey
(handles + deferred cleanup patterns sharing one id), and
community_service_integration, removing the "Handle already taken" re-run risk
against the persistent PDS.
Validated: make test-all fully green; 7 no-cache integration runs + 3 E2E runs,
zero failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
make test-all was failing across several E2E tests against real infra. The root
causes were independent latent bugs that only surface against live Postgres/PDS:
- community suggestions: the pagination and status-transition subtests created
more than 3 suggestions as a single user, tripping the 3/day rate limit (429).
Spread creation across distinct submitter DIDs (also more realistic, since
listing/status are over all submitters). The dedicated rate-limit test is
left intact.
- kagi unfurl: kite.kagi.com was intentionally made unsupported in f8efe46, but
TestPostUnfurl_KagiURL still asserted it was supported. Inverted it into
TestPostUnfurl_KagiKiteExcluded, which asserts the exclusion is enforced
(and drops the flaky network dependency).
- PDS handle collisions: ~24 handles used time.Now().UnixNano()%1000000, which
collapses to 6 digits and collides against accounts persisted by prior runs
("Handle already taken"). Routed all PDS handles through the uniqueTestID()
helper and switched uniqueTestID() to base36 (Unix-seconds + atomic counter)
so prefixed handles stay under the 18-char PDS label cap ("Handle too long")
and stay collision-free across runs.
- firehose panic: 4 Jetstream read helpers (vote/comment/post_delete/post_e2e)
kept calling ReadJSON after a read-deadline timeout, which gorilla/websocket
treats as a permanent read failure; after 1000 dead reads it panics and
crashes the whole test binary. Added the established consecutiveTimeouts
giveup guard to match the already-guarded helpers.
Test-only changes. `go build ./...` and `go vet ./tests/integration/` are clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caddy's `header` directive REPLACES the upstream Content-Security-Policy rather
than merging, so the site-wide CSP was overriding the tighter, Cloudflare-
allowing CSP that TurnstileHandler sets on the widget host page — blocking
Cloudflare's challenge api.js and breaking the captcha. Apply the global CSP to
every path except /m/turnstile.html, letting the AppView's per-page policy stand.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a bot-protected signup handshake so new accounts must pass a Cloudflare
Turnstile challenge before a PDS invite code is issued. The mobile app loads a
server-rendered widget page, solves the challenge, and exchanges the token with
the AppView, which verifies it and mints a single-use invite via the PDS admin
API. Signup stays closed (not bypassed) when the feature is unconfigured.
Changes:
- New social.coves.actor.requestSignupToken XRPC endpoint: verifies a Turnstile
token then mints a single-use invite (com.atproto.server.createInviteCode),
rate-limited 5/min/IP on top of the global limit
- New TurnstileVerifier interface + Cloudflare impl; fail-closed on any
transport/decode/verification error, raw token never logged
- New /m/turnstile.html widget host page for the mobile WebView, CSP locked to
Cloudflare's challenge origin; returns 503 when TURNSTILE_SITE_KEY is unset
- Distinct, observable error types so ops can alert on misconfig/outage apart
from user rejection: ErrSignupTokenDisabled/CaptchaUnavailable/
ErrPDSAdminUnavailable (503), InvalidCaptchaError (403), InviteMintError (500)
- Harden rate limiter: export GetClientIP, make it XFF-spoof resistant (prefer
X-Real-IP, else rightmost XFF, else RemoteAddr sans port); named limiters for
attributable 429 logs; Stop() to end the cleanup goroutine; log every 429
- Replace error-string matching with the ErrUserAlreadyExists sentinel (errors.Is)
- Wire TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY/PDS_ADMIN_PASSWORD through main.go,
docker-compose, and .env files (Cloudflare always-pass test keys in dev)
- Remove the beads (bd) issue tracker: delete AGENTS.md, drop Issue Tracking from
CLAUDE.md, gitignore .beads/
- Add unit/e2e tests: turnstile, signup-token handler, rate limiter, user errors,
turnstile template, signup-token e2e; update all NewUserService call sites
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The previous fix disabled the unfurl-thumbnail fallback for all trusted
aggregators to stop kagi from inheriting kite.kagi.com's misleading default
og:image. That also killed streamable.com thumbnails for the reddit-highlights
aggregator, which intentionally sends only the URL and relies on the server's
oEmbed unfurl to fetch the thumbnail.
Move the exclusion to the actual source of the problem: kite.kagi.com itself.
Its server-rendered <title>, og:title, og:description, and og:image are all
the same default fallback for every URL (the top story, randomly localized
by path), so unfurling produces wrong metadata regardless of caller.
This restores the original two-mode unfurl behavior (full for users,
thumbnail-only for trusted aggregators) and makes kite.kagi.com explicitly
unsupported in IsSupported. Result:
- reddit-highlights + streamable → oEmbed thumbnail restored
- kagi w/ primary_image → unchanged (aggregator thumbnailUrl path)
- kagi w/o primary_image → no unfurl (kite unsupported), no wrong image
- regular user pasting a kite URL → no enrichment, but no wrong one either
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trusted aggregators are authoritative on embed metadata. Falling back to
unfurl when a trusted aggregator omitted a thumbnail meant scraping the
kite.kagi.com SPA, which returns the same default og:image for every URL —
so every image-less Kagi story was getting the same wrong thumbnail (e.g.
the top story's image attached to unrelated posts further down the feed).
Now: no thumbnailUrl + trusted aggregator = no thumbnail. The existing
"No fallback - aggregators only use RSS feed thumbnails" comment finally
matches the behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the brittle HTML-description parser with a join against Kagi's sibling
JSON feed. RSS XML still supplies the authoritative GUID/link/pubDate/categories;
the JSON cluster supplies pre-structured short_summary, talking_points,
perspectives, quote, and articles, eliminating the HTML-scraping surface area.
Also add inline-citation rendering: Kagi embeds [domain#N] markers in its
structured fields, indexed 1-based within each per-domain bucket of
cluster["articles"]. These now become inline link facets in rich text and are
cleanly stripped from plain-text embed.description and dedup input.
Changes:
- Replace src/html_parser.py with src/json_parser.py (KagiJSONParser); delete
the HTML parser, fixture, and tests
- Add src/citations.py: build_index / strip / tokenize for [domain#N] markers,
with [common] non-link marker handling and unresolved-marker warnings
- Add JSONFetcher in src/rss_fetcher.py with retry + payload validation; add
max_retries >= 1 guard to RSSFetcher and JSONFetcher
- Main: derive sibling .json URL from configured .xml URL; resolve XML->JSON
clusters via cluster_number+1 fast path with title-scan fallback and a
0-resolved tripwire; convert published_parsed -> tz-aware datetime; use
citation-stripped summary for embed.description, dedup input, and state
- Richtext formatter: emit citation markers as link facets across summary,
highlights, perspectives, and quote; add add_italic_span so quotes wrapping
inline citation facets stay italic as a whole; suppress orphan " - " when
quote attribution is empty
- Tests: add test_json_parser.py, test_citations.py, JSONFetcher tests,
citation-handling tests across formatter and main (195 passing, 90% cov)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Coves is now live on the App Store and Google Play, so the landing page
should link out instead of showing "Coming soon" placeholders.
Changes:
- Replace placeholder URLs with real store listings
(App Store id6758530907, Google Play social.coves)
- Convert placeholder <div> buttons to <a> tags with target="_blank"
and rel="noopener", restoring "Download on the" / "Get it on" labels
- Remove the now-unused .app-button-placeholder CSS rules
- Update template tests to assert the configured store URLs render
in place of the removed "Coming soon" copy
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The semantic deduplication feature added in ae84bee uses the Anthropic
SDK, which requires ANTHROPIC_API_KEY at runtime. Cron runs in a
separate environment and only sees variables written to
/etc/environment by the entrypoint. The previous whitelist only
included COVES_ and PATH, so the dedup step failed under cron.
Changes:
- Add ANTHROPIC_ prefix to the printenv whitelist in docker-entrypoint.sh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds an LLM-based dedup layer to catch stories that share an event but
differ in GUID/headline (same report from different outlets, rewrites,
etc.). Exact-GUID filtering still runs first; semantic dedup only
considers stories within the same feed posted in a configurable
lookback window.
Changes:
- New SemanticDeduplicator using claude-haiku-4-5 with a tool-use
schema for structured duplicate reports; fails open on transient
Anthropic API errors so posting is never blocked by network issues
- DedupConfig (semantic_enabled, similarity_threshold, lookback_days)
with validation, wired through ConfigLoader and config.example.yaml
- Aggregator reworked into three phases: GUID filter -> semantic
filter -> post, with per-feed counters for new/failed/exact/semantic
- StateManager.mark_posted now stores title + summary_snippet, and
get_recent_stories returns the lookback window for comparison;
state writes are now atomic (tempfile + os.replace)
- Requires ANTHROPIC_API_KEY when semantic_enabled is true
- Tests: unit tests for SemanticDeduplicator, state manager recent
stories + atomic write, and main pipeline integration; live test
suite gated behind a `live` pytest marker (excluded by default)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement a complete community suggestions feature allowing users to propose
ideas and vote on them. This is off-protocol (not stored on PDS/firehose)
and uses PostgreSQL directly for storage.
Changes:
- Add CRUD endpoints for community suggestions (create, get, list)
- Add voting with toggle semantics and atomic vote count updates
- Add admin-only status management (open/under_review/approved/declined)
- Add rate limiting: 3 suggestions/day per user, 10 req/min create, 30 req/min vote
- Add PostgreSQL migration (030) with community_suggestions and suggestion_votes tables
- Add repository with row-level locking for consistent vote counting
- Extract shared xrpc.WriteError() helper, refactor adminreport to use it
- Add Caddy proxy port (8080) to mobile port forwarding script
- Add comprehensive E2E integration tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
XRPC method names in atProto follow camelCase convention. The getProfile
endpoint was incorrectly using all-lowercase "getprofile".
Changes:
- Rename route from social.coves.actor.getprofile to social.coves.actor.getProfile
- Update startup log message, handler comment, and docs
- Update all E2E and integration test URLs and error messages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Narrow the Caddy /api/* catch-all to only proxy /api/me to the Go
backend, since SvelteKit now owns /api/auth/* and /api/proxy/*. Update
the dev script to auto-detect the coves-frontend directory (falling
back to kelp) and switch from npm to pnpm.
Changes:
- Replace broad /api/* proxy rule with specific /api/me route
- Add coves-frontend directory auto-detection in web-dev-run.sh
- Switch frontend dev server from npm to pnpm
- Normalize Caddyfile.dev indentation to tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Refactors the mobile-only OAuth redirect URI system into a configurable
allowlist that supports both mobile apps (custom schemes + Universal Links)
and web clients (HTTPS redirects). Adds Caddy-based reverse proxy for web
frontend development, enabling same-origin cookie sharing between Vite
frontend and Coves backend.
Changes:
- Refactor isAllowedMobileRedirectURI() into OAuthHandler.isAllowedRedirectURI()
with BuildAllowedRedirectURIs() builder for the configurable allowlist
- Add smart redirect: HTTPS clients get direct HTTP redirect, custom scheme
clients get the intermediate redirect page
- Add Caddyfile.dev reverse proxy config (Vite :5173 + Coves :8081 on :8080)
- Add scripts/web-dev-run.sh for combined backend+frontend dev startup
- Add Makefile targets: run-web, web-proxy, web-proxy-bg, web-proxy-stop
- Auto-run db-migrate before server start in make run and make run-web
- Update OAuth client comments to clarify ATProto loopback client_id spec
- Add PAR request debug logging in dev auth resolver
- Update all security tests to use OAuthHandler instance methods
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement one-directional user blocking following the atProto write-forward
pattern. Blocks are written to the blocker's PDS repo and indexed via the
Jetstream firehose consumer into a new user_blocks table.
Changes:
- Add social.coves.actor.block lexicon and DB migration (029)
- Add userblocks domain package (service, repository, interfaces, errors)
- Add XRPC endpoints: blockUser, unblockUser, getBlockedUsers
- Extend Jetstream consumer to index block create/delete events
- Filter blocked users' posts from timeline, discover, and community feeds
- Filter blocked users' comments from comment threads
- Hydrate viewer.blocking on profile responses for authenticated viewers
- Refactor test helpers: DRY PDS client factories, atomic counters
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>