The box's compose got these during the security audit and the repo copy
did not, so a rebuild from the repo would have undone them:
- Redis requires a password (FN_REDIS_PASSWORD, written to a config file
inside the container; healthcheck via REDISCLI_AUTH); FN_REDIS_URL
carries it.
- The internal network is internal: true, so the database and Redis have
no route off the box. The relay reaches S3 and APNs through web.
- relay, redis and obfs4bridge drop every capability; db keeps the five its
entrypoint needs. All four set no-new-privileges.
- obfs4bridge is on web only.
.env.example documents FN_REDIS_PASSWORD. The deploy itself uses the
box's compose, so this changes nothing that is running.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The route landed in #259 and the variable went into the box's compose by hand,
because a deploy alone did not pick it up: this file lists every variable
explicitly under environment: and interpolates from .env, so a variable present
only in .env reaches the container not at all. The relay restarted from the new
image and still answered 404 until the compose named it.
That left the only copy on the box, one deploy away from being dropped, and the
FLOSS/fund listing depends on the path it enables. Now the repository carries it
and .env.example documents it, so a fresh clone and a rebuilt compose both keep
working.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The FLOSS/fund directory listed the flamenet-messenger project's repository
link as unverified: sharing codeberg.org with the manifest proves nothing,
because anyone can serve a manifest from codeberg.org. The spec's own example
is a file committed inside the repository it vouches for.
This file names the manifest, which lives in flamenet-e2e and lists this
repository as the flamenet-messenger project. One line, mirrored to Codeberg
with the rest of the tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scripts/check-doc-pages.py failed the PR: configuration.leaf is generated from
docs/CONFIGURATION.md and hashed against it, so documenting a variable without
rebuilding the page leaves the served page saying something older.
Build with FLAMENET_E2E_DIR pointed at the engine AT THE PIN in .e2e-ref. With
a sibling checkout on the engine's main instead, spec.leaf and engine.leaf
regenerate from unpinned content and the check fails on two more pages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
check-configuration-doc.py failed the PR: that file says it lists every
variable the relay reads, and the new one was missing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven .claude/worktrees/agent-* directories reached the index on 2026-10-15.
They are other sessions' checkouts of this repository, so they commit as
gitlinks pointing at commits no clone can fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
funding.json is how FLOSS/fund takes applications: a manifest in the repository
rather than a proposal, submitted to their directory by URL. Its provenance rule
is that a manifest served from a code host while claiming a hostname needs that
hostname to confirm it, which is what .well-known/funding-manifest-urls is for.
FN_FUNDING_MANIFESTS holds the URLs, whitespace or comma separated, and anything
not beginning with https:// is dropped at config time. Unset means the route
404s, for the same reason the contact addresses render as nothing when
unconfigured: a relay somebody else runs must never tell a funder that
donations for it reach us. The manifest itself carries a legal name, a contact
address and bank details, so it stays in the repository and out of this binary.
Verified against a local relay: 404 with the variable unset, 200 with it set,
and an http:// entry passed alongside an https:// one was filtered out rather
than served.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Block list: a blocked account that is unlisted and not on the caller's
buddy list comes back as "User #<id>" with an empty slug. Rows and shape
are unchanged, so unblock still works in both apps.
- Vouching: a suspended account can no longer be issued a vouch (403
account_suspended).
- Federation: outbound deposit, get and fetch run the outbound address
guard, with allowlisted peers exempt as in PeerDirectory.
- Email: control characters in an address are refused as bad_email, and
the mailer refuses such a recipient and strips them from headers.
- Key log: inclusion, consistency, device and subject reads are metered at
3600 per address per hour each, like the head.
Not changed: email_taken stays (both apps map it), strict account tokens
stay off until the account service stamps iss, aud and jti, and the
credential and current-password hardening needs an app update first.
swift test: 1015 of 1015 pass, 6 new.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checks, swift and image all green on dca5333e. Two defects only the Linux build could show: TodayFile.Key stored a Curve25519.Signing.PublicKey in a Sendable struct, which swift-crypto does not allow and CryptoKit does, and BadgeCountTests still called Routes.placeDeposits, which wave 2 split into resolveDeposits plus Delivery.write.
`Routes.placeDeposits` is gone: wave 2 split it into `resolveDeposits`, which
turns a peer's envelopes into `Delivery.Item`s, and `Delivery.write`, which
inserts them and then wakes and pushes. The test still called the old one and
the Linux test build stopped on "type 'Routes' has no member 'placeDeposits'".
It now does what the route does, in the same two steps, and still asserts the
same thing: a deposit is written with `announced` false, so the badge does not
count it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The relay's `swift` job failed on this and `swift build` on macOS did not:
TodayFile.swift💯 error: stored property 'key' of 'Sendable'-conforming
struct 'Key' has non-sendable type 'Curve25519.Signing.PublicKey'
`Curve25519.Signing.PublicKey` is Sendable in Apple's CryptoKit and is not in
swift-crypto, which is what the Linux build uses. The struct now holds the 32
raw bytes, which are Sendable everywhere, validates them in `init` exactly as
before, and rebuilds the verifying key at the one place a signature is
checked. A key that passed `init` rebuilds, so the `?? false` is unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 conflict hunks across 8 files. Wave 2 landed on main while this branch sat,
and the two sides rewrote the same send path in different directions.
Routes.swift and RoutesFederation.swift: wave 2 extracted the write, the wake
and the push into `Delivery.write`, and this branch still had the inline
version plus one thing wave 2 had no idea about, the `announced` column. The
extraction is kept; `Delivery.Item` gains `announced`, defaulting false as
`Envelope.init` does, and `Delivery.insert` writes it. Without that the model,
the migration and `badgeCount` would all have merged cleanly and every badge
would have read zero, because nothing on the surviving path ever set the
column. The submit route passes `!silent`; a federated deposit passes false,
since nothing announced it.
docs/CONFIGURATION.md: the union. Main lists six outbound connections with the
setting that enables each; this branch added the Today fetch and dropped the
other five. Seven are listed now.
PostgresMigrationTests: both sides' tests, with the `LockedRows` actor left
last in the file.
.e2e-ref moves to d0274ca0, the current flamenet-e2e main. The old pin was
310c315, which was a branch head and not on main at all.
The three generated doc pages were not resolved by hand. `check-doc-pages.py`
reported spec, audit, engine and self-hosting all stale against their sources,
so `Scripts/build-doc-pages.sh` ran against the new pin and all nine pages now
match. That also fixes the spec page's section numbers: flamenet-e2e moved
anonymous reads and contact links to 8.13 and 8.14 to make room for the push
token and the Today file.
Eight more references renumbered by hand, in Wire, RoutesAnonymous,
RoutesDiscovery, ContactLinkPageTests and AnonymousReadsTests. The remaining
8.11 and 8.12 references are about push and the Today file and are correct.
`swift build` is clean on Swift 6.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
checks, swift and image all green on c4d50f45. Worth recording: the image job also passed on main at 6d1013dc without this fix, so the Postgres readiness problem is a race rather than a hard failure. It failed on ebdfc87e and passed on 6d1013dc with the same gate, which is the definition of one.
The image job is red on main and the deploy has not run. The gate's readiness
check was wrong in both directions, and the two failures looked nothing alike.
On mac1 `docker exec <db> pg_isready` never succeeded inside 90 s, and the
gate said only "never became ready". On mac2, with the diagnostics from #253
in place, it succeeded 2.4 seconds after `docker run`, and the candidate's own
log says what happened next:
Opening new connection for pool failed ... Connection refused (errno: 111)
Fatal error: Error raised at top level: PSQLError(code: connectionError ...)
The postgres image runs initdb against a bootstrap server on the unix socket
and then restarts, so a local-socket `pg_isready` can answer "accepting
connections" while nothing is listening on TCP. The relay only speaks TCP, and
it exits on a refused first connection rather than retrying, so that one early
answer ended the run: the 90 seconds that followed were spent waiting for
/health from a container that had already died.
Readiness is now checked the way the relay will use it, over TCP from a
separate container on the gate's network, and twice in a row so the restart in
the middle of the init cycle is not mistaken for the end of it.
Worth a separate decision, not changed here: the relay exits on one refused
connection to Postgres. In production that is a crashloop until the database
answers, which is survivable and noisy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
swift, checks and image all green on 404468d9. The image job passed on mac2 with the staging gate, which is what #253 fixed; on mac1 it had failed twice at the same step.
Three jobs green on 15737e05, the image job among them: pinned to mac2, it built the candidate and passed the staging gate that had failed twice on mac1.
The image job failed on PR #252 and passed on main with no code difference
between them that touches CI. The runner is the difference: run 434 built on
mac2 and passed, run 441 built on mac1 and failed at "staging postgres never
became ready", on the same two commits. mac1 has the smaller OrbStack
allocation, and the gate starts three sibling containers on the host daemon.
The job now names mac2 rather than taking whichever runner is free.
The gate also prints the container's state and log when Postgres does not come
up. "Never became ready" covered both a Postgres still starting and a
container that never ran, which need different fixes, and mac1 said nothing
further, so 90 seconds of waiting read as a relay bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's configuration-doc check found FN_ACCOUNT_TOKEN_ISSUER,
FN_ACCOUNT_TOKEN_STRICT and FN_SIGNIN_MAX_PER_IP read but not
documented. configuration.leaf and relay.leaf regenerated with
Scripts/build-doc-pages.sh; 9 generated pages match their sources.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The streaming upload path repeats the account check before reading the
body, and its copy still refused any subject without a row on an
issuing relay. Same rule as Routes.noteAliveOrRefuse now: refuse only
ids in deleted_accounts. Conformance 266/0/2 (was failing 6 in
sealed attachments); full suite 971/971 twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WakeTests "a send that commits between the cursor query and the wait"
failed in full runs (14 s, 36 s, 42 s against a 5 s bound) and passed
alone. The wake was never lost: with timestamps, the send committed and
signalled at +13.41 s and the poll returned at +14.12 s. The 13 s went
before the send, in a process-wide stall.
A `sample` taken during a stall showed all 8 cooperative threads busy:
7 in Bcrypt.hash at the default cost 12, called synchronously from
fixtures (RosterTagTests, MobileRoutesTests, MobileExtrasTests,
PasswordResetTests account helpers), and 1 in blockingShutdown's
semaphore. One cost-12 hash is 0.31 s in a debug build; cost 4 is
1.3 ms. Every fixture now hashes at cost 4, as WaveZeroBSupport already
did, and a source scan fails if one goes back to the default. The full
run drops from about 60 s to 45 s.
WakeTests now times the poll from the moment the send committed, so a
stall before the send cannot read as a lost wake. With the product's
pending flag disabled it still fails, at 31.8 s.
DeliveryOrderPostgresTests counted every ungranted advisory lock on the
server, including other suites' key-log appends in their own databases,
so the second send could start before the first had inserted ("free"
got id 1). The wait now counts only this database's lock on holdKey.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Uploads are authenticated before the body is read: token, proof headers,
device and account are checked first, then Content-Length (required, 411
without it) against attachmentMaxBytes and the rate limits. The body then
streams to storage while it is counted and hashed. The proof signature,
which covers the body's SHA-256, is checked after the last byte, and a
blob whose proof fails, or whose body ends short of Content-Length, is
deleted.
S3 PUTs are signed UNSIGNED-PAYLOAD and sent with a known length through
AsyncHTTPClient. The digest is unknown until the last byte, so
x-amz-checksum-sha256 cannot go in a header, and B2 refuses aws-chunked.
GETs stream the bucket's body to the client, Range still asked of the
bucket. The disk store writes to a .partial file and renames it.
Vapor runs a streamed body's closure a second time when writing the
response fails; the bucket body is handed out once, because a second read
traps the process.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds flamenet_wake_errors_total and counts both catch blocks in
RoutesWake (send and wait). Only the replica (Redis) path can fail;
the in-process waiter cannot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RelayCapacity.shared is process-global. WakeTests holds 21 polls at once,
which moved the exact peakHeld that CapacityTests asserts. Both now take
one lock (ProcessEnvironmentLock.withExclusiveHeldPolls). The wake-race
test allows 5 s against a 30 s hold, so a loaded runner does not fail it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Presence by token: three queries per request (device tags, account
tags, users) with digest IN (...), down from two or three per token.
New index roster_tags_digest; device_roster_tags_digest already existed.
- Key log head: KeyLogHeadCache serves (size, root, signature) after one
MAX(seq) read, and loads only leaves past the cached seq. It refreshes
on the first read after an append commits, never inside the append's
transaction, and stays correct across replicas. GET /e2e/log/head is
metered at 3600 per address per hour.
- Retention: expired devices picked by one SQL query (ids only), orphaned
push tokens deleted with NOT EXISTS, used prekeys deleted with a bare
`used` predicate matching the new partial index prekeys_used_created.
- noteAliveOrRefuse reads only suspended_at per request. Last-seen is
written at most once per heartbeat and once per 60 s per account,
tracked in memory per process.
- AddScanIndexes builds both indexes CONCURRENTLY on Postgres: FluentKit
runs migrations outside a transaction. An INVALID leftover is rebuilt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- FN_FEDERATION_PEERS takes host, host=key or host=key1|key2. A pinned peer
whose descriptor publishes another key is refused (keyNotPinned, 401).
Boot warns for each bare host and for each malformed pin.
- Content reports: FN_REPORT_RATE (default 10/hr) per reporter through the
existing limiter, 429 over it. One mail per report replaced by
ReportDigest: at most one mail an hour listing reports since the last,
none when there are none. Watermark in a new report_digests table, seeded
at the newest existing report; content_reports is unchanged.
- RelayMetrics counters and a NIO listener on FN_METRICS_PORT (off by
default, 127.0.0.1 by default) answering only GET /metrics. Boot refuses
FN_METRICS_PORT equal to FN_PORT.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The four repositories moved from GitLab to Gitea. The hand-written
source page (en, es) and the layout's sameAs list now point at
https://git.extant2000.com/extant2000/<repo>; all four answer 200
and clone without credentials. Generated doc pages still carry
GitLab links until their source documents change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Relay review 2026-10-14, three owner-approved changes, each safe to deploy
before anything else moves.
Sign-in: a second failure bucket per address (FN_SIGNIN_MAX_PER_IP, 30 per
15 minutes, 0 = off) sits beside the address+username pair, so one address
can no longer try a password against every username. A success clears only
the pair. Every bcrypt hash and verify in AccountRoutes and PasswordReset now
runs on application.threadPool through offLoop.
App credentials: the relay mints every one as 32 bytes from the CSPRNG
(randomHexToken). New rows are stored as sha256:<hex>; a bcrypt row is
verified once and rewritten as SHA-256 on success, only when the secret has
the 64-hex shape the relay mints.
Account-service tokens: /messenger/session reads iss, aud (string or list)
and jti. FN_ACCOUNT_TOKEN_STRICT (off by default) logs and counts tokens that
lack them, carry the wrong ones, or replay a jti, and accepts them; on, it
refuses them. jti is spent through the replay cache with a TTL to the token's
exp, after the name checks so a refused handle does not burn the token. Boot
fails with strict on and FN_ACCOUNT_TOKEN_ISSUER unset. SECURITY.md has the
rollout order.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One relay keeps a waiter per held poll in memory (WakeHub). The poll
registers once, before its first cursor query, and Delivery.write signals
it after the commit, so a send landing between the query and the wait is
kept and the poll returns at once. A held poll holds no Redis connection.
With FN_RELAY_REPLICAS above 1 wake-ups still go through BRPOP on a Redis
list, behind the same Waiter interface; registration there deletes tokens
left by earlier sends instead of popping them later. A failed wait sleeps
one second before the next cursor query. The Redis pool default is 32 per
loop on one relay and stays 512 with replicas.
The cursor query reads id and payload length first, cuts at the 2 MiB
budget, then loads payloads for the kept rows only. Response shape is
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI's e2e conformance suite (test/relay.mjs at 310c315) mints tokens
for bare subjects on an issuing relay, and the account check refused
them all as deleted. Account delete now lists the id in a new
deleted_accounts table (id and time only), and a missing row is
refused only when the id is listed there. Production had 0 devices
without a user row. Conformance: 266 passed, 0 failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
retryingTransaction now retries PSQLError with SQLSTATE 40P01
(deadlock_detected) or 40001 (serialization_failure), with the same
5 attempts and backoff as SQLite BUSY. No other code is retried.
Postgres has already rolled the attempt back, so a rerun is clean.
Only the outermost call retries a Postgres error. A nested call shares
an already aborted transaction, so it rethrows and the outer call
starts over. This covers KeyLog.append taking the advisory lock inside
a caller's transaction.
Test (FN_TEST_POSTGRES): two connections lock rows 1 and 2 in opposite
order inside retryingTransaction. One gets 40P01, runs again, and both
commit once: 3 attempts, rows end at [2, 2]. Fails with 40P01 before
this change; passed 5 of 5 runs after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>