R9 Web Push, then the honest version: VAPID key, push subscriptions, encrypted pushes, 4.5.0 advertised
The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona; every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and scenarios/push.sh in batch A. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
87529bca49
commit
9b792042e6
43 files changed
+2079
-49
No files matched your search
@@ -94,6 +94,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Infrastructure/
|
||||
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
|
||||
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
|
||||
Push/ Web Push (R9): Vapid (the server's key, ES256 JWTs), WebPushCrypto (aes128gcm, aesgcm),
|
||||
WebPushJob (SendPush, through the `push` client)
|
||||
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
|
||||
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
|
||||
Cli/ AdminCommands (`PrivaPub admin promote|demote|role|create-root|move-persona|delete-root|reset-totp`,
|
||||
@@ -130,6 +132,7 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
|
||||
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
|
||||
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements,
|
||||
WebPush (who is pushed what; WebPushDispatcher on Notifications.Told),
|
||||
FeaturedTags (a persona's featured hashtags, sent and received), Suggestions (who to follow)
|
||||
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
|
||||
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
|
||||
@@ -146,8 +149,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
|
||||
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, lists, filters,
|
||||
media, polls, reactions, reports, scheduled statuses, streaming, tags, trends, cdns,
|
||||
provenance, custom emojis, featured tags, suggestions; the stubs are `StubsController`
|
||||
in `SearchController.cs`
|
||||
provenance, custom emojis, featured tags, suggestions, Web Push (`PushController`), OAuth
|
||||
userinfo; the stubs are `StubsController` in `SearchController.cs`
|
||||
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
|
||||
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
|
||||
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval,
|
||||
@@ -429,6 +432,87 @@ client's role route never does.
|
||||
and `ProtectiveMerge` deletes those of a persona deleted since. Mastodon's account archive has no featured tags, notes or
|
||||
endorsements, so the persona archive leaves them out.
|
||||
|
||||
## Web Push (completion program R9)
|
||||
|
||||
- **The key** (`Models/PushKeys.cs`, `Infrastructure/Push/Vapid.cs`): one P-256 VAPID key pair (RFC 8292), made the first
|
||||
time something needs it and kept in Mongo (one document, id `vapid`; two processes at once keep the first one saved).
|
||||
Its public key, the uncompressed point in URL-safe base64 with its padding (as Mastodon writes `VAPID_PUBLIC_KEY`), is
|
||||
v2's `configuration.vapid.public_key`, an app's `vapid_key` (`POST /api/v1/apps`, `apps/verify_credentials`) and every
|
||||
subscription's `server_key`.
|
||||
- **Subscriptions** (`Models/Social/PushSubscription.cs`, a new collection, unique on `TokenId`): one per Mastodon access
|
||||
token, as on Mastodon, so one persona's (the token's subject) and never its login's. Each holds the endpoint, the
|
||||
client's `p256dh` and `auth` as it sent them, `standard`, the alerts turned on, the policy, and the token itself, which
|
||||
the payload carries back. The alerts are Mastodon 4.5's names (`WebPush.Alerts`: `mention`, `quote`, `status`,
|
||||
`reblog`, `follow`, `follow_request`, `favourite`, `poll`, `update`, `quoted_update`, `severed_relationships`,
|
||||
`moderation_warning`, `annual_report`, `admin.sign_up`, `admin.report`) and Pleroma's `pleroma:emoji_reaction`; those
|
||||
PrivaPub never notifies (the admin ones, `quoted_update`, `severed_relationships`, `annual_report`) are kept and never fire.
|
||||
- **Routes** (`Api/Mastodon/Controllers/PushController.cs`, the `push` scope, a persona's token; Mastodon's parameters as
|
||||
a form or JSON):
|
||||
- `POST /api/v1/push/subscription`: `subscription[endpoint]` (an https URL; plain http only where
|
||||
`Federation:AllowPlainHttp`, which production refuses), `subscription[keys][p256dh]` (a point on P-256),
|
||||
`subscription[keys][auth]` (16 bytes), `subscription[standard]`, `data[alerts][<alert>]`, `data[policy]` (`all` by
|
||||
default, `followed`, `follower`, `none`). It replaces the token's subscription and answers it; 400 without an
|
||||
endpoint, 422 for an endpoint that is no https URL, keys that are no Web Push keys, or another policy.
|
||||
- `GET`: the token's subscription, or 404 `Record not found`.
|
||||
- `PUT`: `data[alerts]` and `data[policy]` replace what it had, as Mastodon's update does (an alert left out is off, a
|
||||
policy left out is `all`); 404 without one.
|
||||
- `DELETE`: `{}`, whether there was one or not.
|
||||
- The answer is Mastodon's `WebPushSubscription`: `id` (a string), `endpoint`, `standard`, `alerts` (every alert, true
|
||||
or false), `server_key`, `policy`.
|
||||
- **What is pushed** (`Domain/Social/WebPush.cs`): `Notifications.Add` and `Notifications.Warn` tell `Notifications.Told`
|
||||
of each notification they keep, never one the notification policy filtered or dropped nor one blocks or mutes hid.
|
||||
`WebPushDispatcher` (a hosted service: the command line pushes nothing) hangs there and queues one `SendPush` job
|
||||
(`JobKind.SendPush`, the last member; dedupe `push|<subscription>|<notification>`, its host the endpoint's) for each
|
||||
subscription of that persona whose alert for the type is on and whose policy admits the sender: `followed`, an account
|
||||
the persona follows; `follower`, one that follows it; the persona itself (its poll ended, the staff's warning) under
|
||||
every policy but `none`. Nothing for a persona `Restrictions` holds back (suspended, its login banned).
|
||||
- **Sending** (`Infrastructure/Push/WebPushJob.cs`). Everything is read again when the job runs: the subscription, its
|
||||
persona (not held back, not deleted), its token (valid, unexpired, its subject the subscription's persona, or the
|
||||
subscription goes), the notification (still the persona's, still pushable, still shown to it by
|
||||
`MastodonMapper.Notifications`). The payload is Mastodon's JSON, at most 3993 bytes (`WebPushCrypto.MaxPayload`):
|
||||
- `access_token`, the subscription's own token, never another's; `preferred_locale`, the persona's default posting
|
||||
language, else the server's first; `notification_id`, PrivaPub's id (a string); `notification_type`, its Mastodon
|
||||
name;
|
||||
- `icon`, the sender's avatar through the media proxy; `title`, Mastodon's English subjects ("You were mentioned by
|
||||
…", "… favorited your post", …); `body`, the post's warning, else its words, else the sender's bio, without tags, 140
|
||||
characters at most.
|
||||
- **Encryption** (`WebPushCrypto`, System.Security.Cryptography only: ECDiffieHellman on P-256, HKDF-SHA-256, AesGcm),
|
||||
a fresh key pair and salt per message. A `standard` subscription gets RFC 8291's `aes128gcm` (RFC 8188's header:
|
||||
salt, record size 4096, our public key as the key id; one record, delimiter 0x02) with `Authorization: vapid
|
||||
t=<jwt>, k=<key>`. Any other gets the draft Mastodon still sends by default, `aesgcm` (two bytes of padding length
|
||||
first) with `Encryption: salt=…`, `Crypto-Key: dh=<our key>;p256ecdsa=<the VAPID key>` and `Authorization: WebPush
|
||||
<jwt>`. Both carry `Content-Type: application/octet-stream`, `TTL: 172800` and `Urgency: normal`, as Mastodon's do.
|
||||
- **The JWT** (`VapidKey.Token`): ES256, `aud` the endpoint's origin, `exp` twelve hours ahead (RFC 8292 allows a
|
||||
day), `sub` `mailto:` the contact email the administrator gave, else the server's address (`mailto:privapub@<host>`
|
||||
for a test server on plain http). Never a persona.
|
||||
- **Answers:** 2xx is done; 404 and 410 delete the subscription; 408, 429, a 5xx, a timeout or a broken connection retry
|
||||
on the jobs' backoff (six attempts, about forty minutes); any other answer, a redirect, or an endpoint that does not
|
||||
resolve to a public address drops the push, logged by the push service's host alone. Four run at a time, four per host.
|
||||
- **decePub:** the first-party client's exchanged persona tokens may hold `push` too (`PersonaExchange.FirstPartyScopes`),
|
||||
so it can subscribe like any other client.
|
||||
- **Lifecycle:** a subscription goes with its token: `/oauth/revoke` (an OpenIddict handler after the revocation),
|
||||
`RootSessions.Revoke` (a password changed or recovered, two-factor turned on, signing out everywhere, a ban, a deleted
|
||||
login), `PersonaRemoval`, a suspension (`AccountModeration`), `admin move-persona`, and any token found unusable when a
|
||||
push is sent. A new subscription made with the same token replaces it.
|
||||
- **Backups:** `PushSubscription` and `PushKeys` are never in a backup (`ServerBackup.Excluded`). A restore leaves the
|
||||
live key as it is, so the key the push services know never changes, and deletes every subscription with the tokens
|
||||
(`ProtectiveMerge`); clients subscribe again once signed in.
|
||||
- **The rest of the honest version** (`docs/MASTODON-API.md`, the walk of every Mastodon 4.3, 4.4 and 4.5 client route),
|
||||
built with it:
|
||||
- `POST /api/v1/conversations/:id/unread` (`ConversationState.MarkedUnread`, until it is read);
|
||||
- `GET /api/v1/domain_blocks/preview` (`following_count` and `followers_count` the block would take, counted as
|
||||
`BlockDomain` takes them);
|
||||
- `POST /api/v2/notifications/clear` and `PUT /api/v2/notifications/policy`;
|
||||
- `GET`/`POST /oauth/userinfo` (the `profile` scope: `iss`, `sub` the actor, `name`, `preferred_username`, `profile`,
|
||||
`picture`; `userinfo_endpoint` in the server's metadata) and `verify_credentials` for a `profile` token
|
||||
(`Scope("read:accounts|profile")`: a scope written `a|b` is either);
|
||||
- stubs: `GET /api/v1/annual_reports/:year` (none) and `POST …/:year/read` (`{}`), `POST /api/v1/accounts` (403, as
|
||||
Mastodon with registrations closed: they are cut), `POST /api/v1/emails/confirmations` (403) and
|
||||
`GET /api/v1/emails/check_confirmation` (`true`), `GET /api/v1/peers/search` (`[]`), and 4.4's
|
||||
`GET /api/v1_alpha/async_refreshes/:id` (404: PrivaPub never sends `Mastodon-Async-Refresh`).
|
||||
|
||||
Both instance APIs then say `4.5.0 (compatible; PrivaPub <release>)` (Mastodon invariant 6).
|
||||
|
||||
## Custom emoji (completion program R7)
|
||||
|
||||
- **The server's own emoji** (`Models/Post/LocalEmoji.cs`, a collection of its own): a shortcode (`[a-zA-Z0-9_]{2,64}`,
|
||||
@@ -487,8 +571,12 @@ client's role route never does.
|
||||
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
|
||||
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
|
||||
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
|
||||
`HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater`'s monthly DB-IP Lite download
|
||||
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in).
|
||||
`HttpClient` for federation. The only other outbound traffic is SMTP, `GeoUpdater`'s monthly DB-IP Lite download
|
||||
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in), and Web Push (R9):
|
||||
the named `push` client, on `SafeHttpHandlerFactory`'s handler (it resolves the name itself and connects only to
|
||||
public addresses, with federation's test-network exceptions; no redirects, cookies or proxy), https only (plain http
|
||||
only where `Federation:AllowPlainHttp`), 5 s to connect and 15 s a request. It POSTs encrypted payloads to the
|
||||
endpoints clients subscribed with, and reads nothing back but the status.
|
||||
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
|
||||
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies). Inbound,
|
||||
an RFC 9421 signature (`Signature-Input`) is verified too, its target against our public address. A persona's own
|
||||
@@ -605,9 +693,14 @@ client's role route never does.
|
||||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
|
||||
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
|
||||
`UseMastodonErrorBodies`.
|
||||
6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays,
|
||||
because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped
|
||||
notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy.
|
||||
6. **Advertise `4.5.0 (compatible; PrivaPub <release>)`** in both instance APIs (`InstanceController.Version`, since R9;
|
||||
the release is the build's tag without its `v`, else its ref or commit), with `api_versions.mastodon = 7`, 4.5's own.
|
||||
The number is the walk's (`docs/MASTODON-API.md`): every client route of Mastodon 4.3, 4.4 and 4.5 is served or
|
||||
answers as a named stub, and none a client relies on is missing. A route Mastodon adds, or one PrivaPub stops serving,
|
||||
goes there first, and the version follows the walk, never a wish; `tools/smoke/mastodon-api.sh` checks it on every
|
||||
deploy. Clients branch on both numbers, so everything a 4.3+ client calls must answer: grouped notifications
|
||||
(`/api/v2/notifications`, its unread count, groups, accounts, dismiss and clear), quotes, Web Push, streaming and the
|
||||
notification policy.
|
||||
The policy is real (`Domain/Social/NotificationPolicies.cs`, applied in `Notifications.Add`): accepted by default,
|
||||
it filters (into a `NotificationRequest` per account, out of every list and count unless `include_filtered`) or drops
|
||||
what strangers, accounts that do not follow the persona (or only for three days), accounts newer than 30 days,
|
||||
@@ -624,6 +717,8 @@ client's role route never does.
|
||||
private notes (shown to it alone, never federated), dismissed suggestions. Suggestions never offer the persona itself
|
||||
or a sibling persona of its login, and `familiar_followers` answers no one for every account, since it would tell who
|
||||
follows whom here.
|
||||
9. **A Web Push subscription is its token's** (R9): one per access token, read, changed and deleted only with that token,
|
||||
pushed only its persona's notifications, and its payload carries only that token. It ends when the token does.
|
||||
|
||||
## Media invariants
|
||||
|
||||
@@ -766,6 +861,12 @@ client's role route never does.
|
||||
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
|
||||
something about an avatar gets the same treatment: ask, then record it there.
|
||||
- **Location-ranged posts never federate.**
|
||||
- **Web Push tells a push service when, never what** (R9). Every payload is encrypted to the subscribing client's own
|
||||
keys (RFC 8291, or the aesgcm draft), so the push service (Google's, Mozilla's, Apple's, a UnifiedPush distributor)
|
||||
learns only that this server has something for that endpoint, when, and how big: never the words, the sender or the
|
||||
persona. The VAPID JWT names the push service's origin and the server's contact (the contact email, or the server's
|
||||
address), never a persona. Subscriptions are per token, so per persona: none receives a sibling's notifications, and
|
||||
nothing stored pairs two personas of one login.
|
||||
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
|
||||
through `IInteractionLedger` (`Infrastructure/Statistics`) as an `InteractionEvent` (90 days), but an event never
|
||||
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
|
||||
@@ -868,7 +969,8 @@ client's role route never does.
|
||||
|
||||
**Never in a backup** (`ServerBackup.Excluded`): `InteractionSalt` (owner rule), `Job` and `Delivery` (work in flight),
|
||||
`EmailRecovery` and `EmailVerification`, `openiddict.tokens` and `.authorizations` (sessions), `MaintenanceLock`, `ServerSetup` (a restore
|
||||
never opens setup again), and `AppConfiguration` (its SMTP password; it is made again from appsettings at boot). One
|
||||
never opens setup again), `AppConfiguration` (its SMTP password; it is made again from appsettings at boot),
|
||||
`PushSubscription` (sessions' too, R9) and `PushKeys` (the VAPID key: a restore keeps the live one). One
|
||||
backup or restore runs at a time
|
||||
(`MaintenanceLock`, a heartbeat document; a holder silent for 2 minutes is taken over), and the media janitor purges
|
||||
nothing meanwhile. `BackupScheduler` backs up at `Backups:NightlyAt` (03:30 UTC), or at once when it missed the night;
|
||||
@@ -895,7 +997,8 @@ client's role route never does.
|
||||
nothing holds any more goes there too); the staff's log (`AdminAction`) is the union, so a restore never loses an
|
||||
entry, its own request included, and the server's settings and custom emoji (`LocalEmoji`, R7) are P's: the live
|
||||
settings and emoji outlive a restore, and an emoji deleted since stays deleted;
|
||||
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a
|
||||
5. every session ends (a new `SessionStamp` for each root, persona tokens deleted, and every Web Push subscription with
|
||||
them, R9: clients subscribe again, to the same VAPID key), circuits close, and a
|
||||
`RestoreRecord` (never in a backup) tells what happened (`admin restore --status`).
|
||||
|
||||
Each attempt redoes everything. Refused before anything changed, it is abandoned and recorded, and the server boots as
|
||||
@@ -1154,6 +1257,25 @@ tools/pasture/run.sh down # removes e
|
||||
DM; her endorsement of him and her note on him are hers alone (feat_bob, a sibling persona of the same login, sees
|
||||
neither, and `GET /api/v1/accounts/:id/endorsements` answers nobody else), and feat_bob is never suggested to her; the
|
||||
stubs (translate 422, annual reports, the admin API 403) answer.
|
||||
- **Web Push (`scenarios/push.sh`, needs mastodon and pushsink; after featured in batch A):** push_alice subscribes with
|
||||
the sink's keys; mastouser's mention reaches the sink as aes128gcm, its VAPID JWT by the instance API's key for the
|
||||
sink's origin with TTL and Urgency, and decrypts to a `mention` with her own access token and her notification's id;
|
||||
the mention alert turned off stops it while his favourite is pushed; `policy=followed` stops him until she follows him;
|
||||
`standard=false` is aesgcm (Encryption, Crypto-Key, `WebPush`) and decrypts too, and the new subscription replaces the
|
||||
old; push_bob, a sibling persona of the same login, never gets hers nor she his; DELETE stops the pushes; the sink
|
||||
answering 410 removes the subscription; a token revoked at `/oauth/revoke` is pushed nothing; no subscription is left.
|
||||
Both instance APIs say 4.5.0 and give the key an app registration gives.
|
||||
- **pushsink (`peers/pushsink.sh`, `pushsink/pushsink.cs`, as `pushsink.test`):** a Web Push service of the pasture's
|
||||
own, a .NET file-based app published once into `.pushsink` like flood. It makes each subscription's keys itself, checks
|
||||
what a push service checks (the VAPID JWT: ES256 by the server's key, the instance API's or the one the scenario gave
|
||||
it; `aud` its own origin; `exp` ahead and at most a day; a `mailto:` or `https:` `sub`; TTL), decrypts `aes128gcm` and
|
||||
`aesgcm` with its own code, not PrivaPub's, and keeps what it took. Its API, behind Caddy: `POST /subscriptions`
|
||||
(`{"server_key"}` optional) answers `{id, endpoint, keys: {p256dh, auth}}`; `GET /subscriptions/{id}`;
|
||||
`POST /subscriptions/{id}/answer` (`{"status": 410, "times": 1}`, for the next pushes); `GET` and `DELETE
|
||||
/subscriptions/{id}/received` (each push: when, the status answered, `ok` and the errors, headers, the JWT's claims, the
|
||||
decrypted payload); `POST /push/{id}` is the push service (201, 403 for a VAPID failure, 400 for the rest, 404 for no
|
||||
such subscription); `/health`. PrivaPub reaches it through Caddy as every peer, since its `push` client honours
|
||||
`appsettings.Pasture.json`'s Federation exceptions; nothing new is configured. In memory only.
|
||||
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
|
||||
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
|
||||
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
|
||||
@@ -1318,8 +1440,9 @@ the owner's GoToSocial account.**
|
||||
the server's own pre-deploy backup (`PrivaPub admin backup --kind pre-deploy --db-only`, run from the new build before
|
||||
its migrations, verified, and checked to hold no statistics salt; no deploy while `restore.json` waits),
|
||||
stop → rsync → start, a `127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
|
||||
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration, client credentials,
|
||||
discovery, instance, public timeline). Then it checks that what production should be running is running:
|
||||
Swagger 404, inbox junk 400, unsigned 401) and `tools/smoke/mastodon-api.sh` (app registration with its `vapid_key`,
|
||||
client credentials, discovery, both instance APIs with the advertised version and the VAPID key, public timeline).
|
||||
Then it checks that what production should be running is running:
|
||||
- NodeInfo and the instance API agree on registrations (closed, invitations only);
|
||||
- `/stargazing` says the crawler is on;
|
||||
- nothing signs in (owner decision 2026-10-17): the deploy's own login, `deploy-smoke`, gave its @thepra to the
|
||||
|
||||
Reference in new issue
Block a user