Files
gerbeur/api/services
khannurien 66e402bd91 v4: federated sign-in closes the holes a review found — a callback link made from someone else's sign-in signed the visitor in to that account, a password reset left the user's sessions on other instances open, and events could be lost, reordered or delayed. FederatedSignIn keeps the sign-in's state in sessionStorage (utils/auth/federation.ts) and FederationCallback finishes only a sign-in this tab started. A password reset tells the instances the user signed in to, as a change does. The outbox delivers each instance's events in the order they were made, the first that fails holding back the rest, so a late older update no longer undoes a newer one, and delivers to every instance at once, so a dead one delays none of the others; an event queued during a run is sent by it.
Receiving, a home's keys that can't be fetched answer 502, which the sender retries, instead of 401, which it dropped; a bad update answers 400 rather than 502; a blocked instance's events answer 403; events are limited to a burst of 120 per instance, and a token naming an unknown key refetches the keys at most once a minute. An avatar must be on its issuer's origin, and its URL is versioned by its file's write time instead of updatedAt, so a profile edit no longer makes clients download it again. An instance that doesn't answer can be blocked, keeping its name, though not trusted.

303 backend tests and 703 frontend tests pass, the federation tests 8 times in a row, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:05:11 +00:00
..
v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
2026-10-09 13:16:52 +00:00
v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
2026-10-06 16:08:59 +00:00
v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
2026-10-09 13:16:52 +00:00
v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
2026-10-09 12:54:09 +00:00
v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.
2026-09-29 19:35:53 +00:00
v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.
2026-09-29 19:35:53 +00:00
v4: a /community page shows the site's week and month, from the same figures as the digests, and every recurring server task is a job of one scheduler — the digests' top dumps and community section (who joined, the new public playlists, the chat and the totals) existed only in emails. community-service.ts now gathers them for a period from listTopDumps, listNewUsers and listNewPublicPlaylists, each in its own service and returning the API's Dump, PublicUser and Playlist; the digest builds its sections from the same call, its email-only DumpRow and DUMP_COLS replaced by one Dump mapper that networkOf uses too, and links its community section to the page. The page, /community for the last week and /community/monthly for the month, signed in only and linked from the avatar menu, reads GET /api/community?period=, which serves figures kept in memory and recomputed every GERBEUR_COMMUNITY_REFRESH_MINUTES (15 by default). It shows the four figures, the top dumps and the new playlists as live cards, and the new members as UserCards, the follow tab's user row made shared. Its words are the emails' own: the messages both use are renamed from email.digest.* to digest.*, as digest.weekly.off was. api/lib/scheduler.ts's startJobs runs the digests, the community refresh, the WebSocket ping, the typing expiry and the rate-limit sweep, which main.ts starts, so none of them runs on import any more. PERIOD_MS moves to shared/settings.ts. community is reserved as a category slug, and migration 0022 renames a category that had it, with renameReservedCategories moved out of 0021 into reserved-slugs.ts. A group's dump or playlist cards now fill its column. 262 backend tests and 678 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth light at 1280 and 390px, Smooth dark with mocked playlists, Brutalist dark at 390px and Kanagawa dark at 1280px.
2026-10-09 11:42:01 +00:00
v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
2026-10-09 13:16:52 +00:00
v4: an audio file's waveform is made once by the server, and the player seeks on one accessible scrubber — every player downloaded the whole file a second time and decoded all of it in the browser to draw 150 bars, so a long FLAC cost tens of megabytes of memory per page, and the waveform could only be clicked: no drag, no keyboard, no slider for a screen reader. GET /api/dumps/:id/waveform now answers an audio dump's peaks, which lib/ffmpeg.ts decodes with ffmpeg on first request, mono at 8 kHz and streamed, keeps beside the thumbnails as the response itself, and serves with an ETag (a ten-minute MP3 takes about a second, then a few hundred bytes). The peaks are taken by shared/waveform.ts's peaksOf, scaled to the loudest so a quiet recording fills the track, and the dump form's preview of a file not yet uploaded decodes it with the same function, at 8 kHz in an OfflineAudioContext. Scrubber replaces Waveform and SeekBar: a role="slider" that seeks where a pointer lets go, follows a drag (the clock shows where it is held), takes arrows, Home and End, and draws the bars as one path with the played part clipped by a nested svg, so it moves smoothly and a timeupdate redraws two attributes instead of 150 rects; audio without a waveform, and video, get a plain bar. MediaPlayer reads whether it plays from the element's events alone, so a click no longer reports the change twice, its handles for PlayerProvider are stable callbacks instead of refs to refs, the drag no longer reattaches every listener, and the <source> children that src made dead are gone, with the mime prop. The file, thumbnail and waveform routes resolve their dump, token and caching through lib/dump-media.ts, and what is generated from an upload is named and removed in lib/ffmpeg.ts, the custom thumbnail by dump-service's customThumbnailPath, instead of paths spelled out in each. The test setup's play() and pause() fire their events as a browser does. 277 backend tests and 684 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in the app with a real FLAC on its dump page and in the global player at 1280 and 390px, seeking by keyboard.
2026-10-09 13:34:22 +00:00
v4: weekly digests and monthly recaps go out by email, every email is written once as Markdown in the app's one catalog, and a review of the last two commits removes what they stated twice — users get a weekly digest (what happened to them, what the people and playlists they follow posted, the week's top dumps, the community) and a monthly recap (the month's top dumps and the community), both on by default, in their own style and scheme. digest-service sends what is due every quarter of an hour from digest_schedule (migration 0020), which holds each user's rolling 7- or 30-day period; existing users' first periods are spread over the coming week or month, a digest more than a period late covers the last full one, an empty one isn't sent, and an address like admin@localhost is skipped. Each digest carries a one-click unsubscribe (List-Unsubscribe and List-Unsubscribe-Post, POST /api/users/unsubscribe) and a link to the new /unsubscribe page, which asks before turning that cadence off, since mail scanners open every link; the token (lib/jwt) names the user and the cadence and never expires. The welcome email now says the digests are on. The emails' words moved out of a hand-written api/mail/strings.ts into the Lingui catalogs the interface uses: lingui extracts api/mail/ too, the catalogs moved to a top-level locales/ that both halves read, compile emits ES modules the server imports (api/mail/i18n.ts), messages take ICU plurals, and the labels the settings tab, the unsubscribe page and the emails share are one message each (their French had already drifted apart). Each template writes its email once as Markdown, which is the plain-text part, and EmailKit.render draws its blocks in the style (a lone link is a button, a list item starting with an image a thumbnail row, a numbered list a ranking, --- the footer), so no string is declared for an HTML and a text version; user text is escaped for Markdown, which the welcome body didn't do for usernames, whose _ read as emphasis. paint() set colors before a border-top shorthand that reset them, so every divider was black. The review also gave one home to what was written twice: the locale list (shared/locale.ts, read by lingui.config, the client, the server and its catalog loader), the base style and default scheme (shared/theme.ts), an email's look (lookOf, which digest-service, welcome and reset share), whether email is on (EMAIL_ENABLED, read by email-service and index.html), a setting's column and its default in the digests' SQL (SETTINGS_COLUMNS, DEFAULT_SETTINGS), the unsubscribe token (made once, not parsed back from a URL), AuthProvider's compare-and-store, ThemeProvider's twin style and scheme code (useThemeChoice), the test JWT encoder, and duplicate digest messages. The email palettes stay a flattened copy of the themes' tokens, now under a test that caught Times' secondary and link colors drifting. The Dockerfile copies api/mail and locales so the build extracts the emails' messages and the server finds its catalogs, and deno.json maps @lingui/core for the runtime image. 236 backend tests (new: digest building, sending, catch-up, scheduling and unsubscribing; the Markdown renderer; French from the catalog; palettes against themes; the stagger migration; unsubscribe tokens) and 661 frontend tests (new: the unsubscribe page) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
2026-10-04 22:14:46 +00:00
v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
2026-10-06 16:08:59 +00:00
v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
2026-10-06 16:08:59 +00:00
v4: a moderator's or an admin's @everyone in the chat notifies every user — there was no way to reach everyone at once. The new chat:everyone permission, held by moderators and admins, lets a chat message saying @everyone notify every user but its author and those reading the chat, through notifyEveryone, after notifyMentions, so a user also named is told once, by name; the notification is a user_mentioned with everyone set, which the notifications page and the digest word as "mentioned everyone in the chat". chat_messages.mentions_everyone, sent as ChatMessage.mentionsEveryone, is set from the author's role each time the body is written, so a user's @everyone reaches no one, a moderator's edit of a user's message can't make it reach everyone, and a demoted author's next edit loses it. Only an edit that adds @everyone notifies, so fixing an announcement's typo reaches no one new. The client highlights and chimes by the flag, never by the text; Markdown shows @everyone as a mention only in a flagged message, and never links it to a profile. The chat composer offers @everyone first to those who may say it. No account may be named everyone, in any case: migration 0023 adds the column, flags the moderators' and admins' past @everyone messages, renames a user named everyone to everyone_N and drops an alias everyone. 265 backend tests and 681 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots of the chat at 1280 and 390px, with a moderator's and a user's @everyone and the suggestions open.
2026-10-09 12:54:09 +00:00
v4: a /community page shows the site's week and month, from the same figures as the digests, and every recurring server task is a job of one scheduler — the digests' top dumps and community section (who joined, the new public playlists, the chat and the totals) existed only in emails. community-service.ts now gathers them for a period from listTopDumps, listNewUsers and listNewPublicPlaylists, each in its own service and returning the API's Dump, PublicUser and Playlist; the digest builds its sections from the same call, its email-only DumpRow and DUMP_COLS replaced by one Dump mapper that networkOf uses too, and links its community section to the page. The page, /community for the last week and /community/monthly for the month, signed in only and linked from the avatar menu, reads GET /api/community?period=, which serves figures kept in memory and recomputed every GERBEUR_COMMUNITY_REFRESH_MINUTES (15 by default). It shows the four figures, the top dumps and the new playlists as live cards, and the new members as UserCards, the follow tab's user row made shared. Its words are the emails' own: the messages both use are renamed from email.digest.* to digest.*, as digest.weekly.off was. api/lib/scheduler.ts's startJobs runs the digests, the community refresh, the WebSocket ping, the typing expiry and the rate-limit sweep, which main.ts starts, so none of them runs on import any more. PERIOD_MS moves to shared/settings.ts. community is reserved as a category slug, and migration 0022 renames a category that had it, with renameReservedCategories moved out of 0021 into reserved-slugs.ts. A group's dump or playlist cards now fill its column. 262 backend tests and 678 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth light at 1280 and 390px, Smooth dark with mocked playlists, Brutalist dark at 390px and Kanagawa dark at 1280px.
2026-10-09 11:42:01 +00:00
v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
2026-10-09 13:16:52 +00:00
v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
2026-10-06 16:08:59 +00:00
v4: every page and API URL follows one of two rules, written down in API.md — pages are a hierarchy where @name is a user and any other root word a category or an app page, tabs and views are plain segments and a default view is the bare path; API resources are /api/<plural>/<key>, a user's key being an id or @name, the caller's things under /api/me, media a sub-resource, lookups by another key a query filter. ~ is gone: the feed is /, /hot, /new, /journal, /follow and /music/hot, a profile /@bob with its overview and the tabs dumps, upvoted, playlists (her own only), follow (users, and /follow/playlists), invitees and manage (role and name, for user:manage), which replace the separate UserDumps, UserUpvoted and UserPlaylists pages; /settings holds the account (now with the username) and /manage the category manager, both linked from the user menu and one's profile. Dumps and playlists live under their owner, /@bob/dumps/<slug> and /@bob/playlists/<slug>, named by slug alone: migration 0021 re-slugs them to the bare slugify(title), unique per owner and kind (the oldest keeps it, clashes get -2), renames the categories whose slug v4 reserves (hot, follow, settings…), and rewrites this site's v3 links in dump notes, comments, chat, and playlist and profile descriptions. A slug changes only when edited, through the edit forms' new link field, which proposes slugify(title) as the title changes; a username changes from /settings or, for user:manage, from /@name/manage. Every slug or name left behind stays an alias of its item or user for good (dump_slug_aliases, playlist_slug_aliases, user_aliases), so old links redirect without chaining and nobody else can take one; slugs change 3 times a day per item, names once in 30 days, moderators exempt. An alias, or a name in another case, answers the item with its current path: the pages put it in their URL (useCanonicalPath, without fetching again), and the OG middleware answers a 301. shared/paths.ts builds and parses every page path, for the client's links, the emails' (digest-service lost its own builders and its id fallbacks, and links /follow and /settings) and the server's matchers (OG tags, self previews, backlinks, self-url); shared/slug.ts holds slugify and the format categories share. Dumps and playlists carry ownerUsername and a required slug, and notifications join their users' current names and their items' paths each time they are sent or read, so renames reach them and a deleted item's has no link. The API was renamed per the plan's table: POST /api/users and /api/sessions, /api/password-resets, /api/me (with settings, password, avatar, media-token, playlists?contains=, follow, feed?from=, notifications), /api/users/:key/{dumps,upvoted,playlists,follow/users,follow/playlists,invitees,avatar} and by path /api/users/:key/{dumps,playlists}/:slug, /api/dumps?url= and ?category=, /api/dumps/:id/{file,thumbnail,metadata}, /api/playlists/:id/cover, /api/remote/{preview,image,bandcamp,youtube} (one router), and /api/unsubscribe, the v3 POST /api/users/unsubscribe staying for the digests already sent. A stored default feed tab "followed" reads as "follow" once. dumps.slug and playlists.slug stay nullable, since SQLite can't add NOT NULL without rebuilding tables others reference; every writer sets them and unique (user_id, slug) indexes hold them. 254 backend tests (new: migration 0021 on a v3 fixture, slug aliases, swaps and rate limits, username renames, aliases and limits, reads by path and their privacy, the OG 301s, both unsubscribe paths, v4-shaped digest links with no ids, notification joins, shared paths and slugs) and 668 frontend tests (new: the profile's tabs and follow views, Settings, Manage, the link field, canonical URLs on the dump, playlist and profile pages, bare default feed paths) pass, and tsc, eslint, deno check, deno lint, deno fmt and doc:lint are clean; the migration was also run on a copy of the development database.
2026-10-06 16:08:59 +00:00
v3: fix the backend's correctness findings — each by a rule shared across the code rather than one more special case. Link previews: readTextCapped decoded every page as UTF-8, so a Latin-1 page came out garbled unless it went through the curl fallback, which re-encoded it on its own; pages are now decoded in the charset their Content-Type or <meta> declares, and the curl fallback hands the bytes over as they are. The image proxy served and cached for a day an error answer that claimed an image type, and read a body without Content-Length whole before checking its size; it now refuses an error status and reads through the same capped reader. The icons task's Twemoji fetch gives up after 15 s instead of holding the start up, and extractLinkHref, which only its tests used, is gone. Backlinks matched a linked URL against the raw url column, so https://x.com/ missed a dump posted as https://x.com; they now compare canonical URLs, as reposts do. The four LIKE searches put the query in raw, so % and _ were wildcards and the mention search for "a_b" found "axb"; lib/like.ts escapes them. Every list query now treats a malformed row the same way, a 500 through model/rows.ts's parseRows, where ten of them silently dropped it; the playlist dumps query uses the model's column list rather than a hand-written copy, and the aliased list is derived from the plain one. The vote and like services commit through transaction() instead of their own BEGIN and ROLLBACK, which would have rolled back a caller's transaction.
2026-09-29 19:35:53 +00:00
v4: what the server and the client both state is declared once — several rules were written out in two or more files, some already drifting apart. A link's site name (its host without www.) was computed in five places, now shared/url.ts's siteHost; dumpFileVersion, which the frontend and the server had to keep equal by hand, is one function in shared/dump.ts; the Markdown flattening of the chat's reply previews and the digest's quotes is shared/markdown.ts's markdownToPlainText, so the digest also keeps an image's alt text; an attachment's URL and how to find one in a text are shared/attachment.ts, which the editor, Markdown and attachment-service read, its id pattern the new UUID_RE that the file and thumbnail routes check ids with. On the server, the Bandcamp, YouTube playlist and link preview caches use one TtlCache (api/lib/ttl-cache.ts) instead of three copies of the same expiry and eviction; the link previews' theme-color and the emails' theme tokens read colours with one parser (api/lib/css-color.ts), and a transparent theme-color now gives no tint; the WebSocket route sends through ws-service's send, the welcome included, so it is type-checked; fetch.ts strips an IPv6 literal's brackets with public-address's bareHostname; and a category's slug is checked by the same validateSlug as a dump's, with its own length. The migrations keep their frozen copies, as they document. 272 backend tests and 677 frontend tests pass, and deno check, tsc, eslint and doc:lint are clean.
2026-10-09 13:16:52 +00:00