The limit service reads a zero cap as no cap, so a tier with no calls
(or no messages, or no attachment bandwidth on upload) became unlimited
where main's FixedLimiter(0) and bytes limiter admitted nothing. The
visitor now answers zero limits itself before asking the service, like
it already did for requests, emails and bandwidth downloads.
Allow(key, rule, n), Refund, Tokens and Usage on a service that keeps a
token bucket and a daily count per key and kind, sharded by key, with the
rule passed in on every call. The server's visitor keeps its identity and
the resolved limits and asks the service; rate.Limiter, util.RateLimiter
and util.FixedLimiter are gone from it (subscriptions stay a per-node
gauge). util.RateLimiter, NewBytesLimiter and BurnTokens had no users
left and are removed.
Behavior changes: refunds work for every kind (a revert on the bandwidth
limiter hands the bytes back), a tier change applies on the next request
without handing back full buckets, a changed tier limit applies without
a tier ID change, and the daily email limit is a real daily cap on top
of the bucket. Step 2 of plans/261025-shared-rate-limits.md.
The visitor key (ip:<prefix> with the configured prefix bits, or user:<id>
for a user with a tier) and the resolution of a visitor's limits from the
server defaults or the user's tier move out of server/visitor.go into the
new package visitor, unchanged. The server keeps thin wrappers, so its
tests are untouched. No behavior change. First step of the shared rate
limits design (plans/261025-shared-rate-limits.md).
/v1/tiers is now the admin API for tiers (GET/POST/PUT/DELETE), instead
of the unreleased /v1/admin/tiers. The public price list for the upgrade
dialog moved to /v1/billing/tiers; GET /v1/tiers still returns it to
non-admins until 2026-11-22, for web apps from before v2.33.0.
The admin socket reuses handleInternal behind a path allowlist
(adminSocketPaths) instead of a separate adminHandler switch, so all
routes are listed in one place.
meshCluster.Ready is already false while draining, since the node acts as passive; the extra check in handleReady was redundant. The fake cluster now follows the same contract.
accessCache now owns everything about reloading: Reload (synchronous, reports changes to the callback after releasing reloadMu), RequestReload (coalesced, at most one pending), the periodic loop, and Close. The Manager only tells the peers and forwards.
The coalescing test made its write before the burst, so it passed even if requests arriving during a reload were dropped. It now holds the first reload after its scan, writes and bursts while it is held, and requires the follow-up reload to see the write. Found by a Codex review.
topicsFromPattern held Server.mu while regex-matching every topic (~313k on ntfy.sh, ~89 ms), blocking every request that creates a visitor; allTopics copies the pointers under the lock and the match runs outside it (also used for deleted users). The access diff (~2.3 ms) no longer runs under the cache's write lock: only reloads replace the maps, and they are serialized. Found by a Codex review.
Account self-deletion (MarkUserRemoved) sent no deleted-user signal and did not end the user's local streams; an admin without access entries kept them, here and on peers. Both deletes now go through userDeleted. Found by a Codex review.
A revoke whose re-check ran between a request's authorization and its stream subscribing missed the stream for good: later reloads find no difference. With the access cache on, the subscribe handlers now authorize once more right after subscribing (an in-memory lookup); the re-check runs after the cache swap, so one of the two always sees the other. Found by a Codex review.
SubscriberUsers kept one user per user ID, so streams of one user with different users as of subscribing (e.g. before and after a demotion) were all judged by whichever came last. topic.CancelSubscribersWhere checks each subscription's own user, outside the topic lock; SubscriberUsers and CancelAnonymousSubscribers are gone. Found by a Codex review.
Added, removed and changed entries, exact and wildcard, per user, and the * form of wildcard topics. The loadCache test helper now builds entries with toACLEntry, as Reload does, so wildcard entries carry their stored topic.
The only cancel still relayed was a deleted user's, so the cluster now signals the event, like AccessChanged: State.DeletedUserIDs (deleted_user_ids on the wire) and Config.UsersDeletedFunc replace SubscriberCancels, the SubscriberCancel type and CancelFunc. The server's handleUsersDeleted ends those users' streams on every topic, for a local delete and a peer's.
The reload diff flattens both snapshots into one map keyed by user and stored topic, replacing four helpers. ReloadAccessCache was only used by tests (the server requests coalesced reloads), so they call maybeReloadAccessCache. killUserSubscriberEverywhere builds the cancel once, applies it locally and broadcasts it.
The only cancel still relayed is a deleted user's, on every topic; access changes go through the access cache reload. Topic and ExceptUserID are gone, and cancelUserSubscribersLocal ends a user's streams on all local topics for both the local delete and the peer cancel. No released version sends these cancels, so the wire format can change.
topicSubscriber no longer stores the user ID next to the user (userID() derives it), which also brings the struct below its size before the user was added. The cancel loops assign the matching subscribers to a variable instead of calling subscribersMatching inside the for header. The periodic access cache reload runs every 63 s: access changes are rare and peers are told directly, so the interval only bounds a missed signal.
topic.Subscribe now takes the subscriber's *user.User (nil if anonymous) and keeps it on the subscription. reauthorizeSubscribers authorizes that user against the access cache, so an access change no longer costs one UserByID query per distinct subscriber user on every matching topic, on every node (a wildcard change could mean thousands). A deleted user's streams end through the relayed cancel (killUserSubscriberEverywhere), as before.
It fires when an access cache reload found a difference, whoever made the change; the cache's content changed, not necessarily because of this node. The cluster's AccessChangedFunc (a peer changed access) keeps its name.
accessWritten now calls AccessWrittenFunc first, so the other nodes start their reloads without waiting for this node's (about one reload, ~20 ms on prod), then reloads the local cache before the write returns.
newAccessCache takes the database and the reload query, so Reload needs no arguments. reloadRequests is now accessCacheReloadRequests, above quit. AccessChangedFunc and AccessWrittenFunc sit below AccessCacheReloadInterval in user.Config, with comments.