Compare commits

...
Author SHA1 Message Date
HampusandGitHub 2426a5769d feat(voice): drive screen share quality from viewer demand (#2421) 2026-09-03 04:49:29 +02:00
HampusandGitHub 66517c925b feat(voice): show a passive badge when a stream underperforms (#2420) 2026-09-03 04:49:07 +02:00
HampusandGitHub 5f90e7d535 fix(api): downgrade oversized video instead of ending the call (#2419) 2026-09-03 04:47:02 +02:00
HampusandGitHub 9d63eb15a9 fix(voice): request a real camera frame rate at capture (#2418) 2026-09-03 04:46:32 +02:00
HampusandGitHub c97bc53342 refactor(voice): remove screen share codec renegotiation (#2417) 2026-09-03 04:46:11 +02:00
HampusandGitHub f5f60c66e7 refactor(voice): remove adaptive screen share quality system (#2416) 2026-09-03 04:41:04 +02:00
HampusandGitHub 3e15b97c8c fix(voice): stop clamping stored video quality preferences (#2415) 2026-09-03 04:36:04 +02:00
HampusandGitHub 6c08813f9b feat(voice): scale screen share bitrate to the selected rung (#2414) 2026-09-03 04:35:37 +02:00
HampusandGitHub 8158d44732 fix(voice): keep screen share resolution under constraint (#2413) 2026-09-03 04:35:12 +02:00
HampusandGitHub 9bd0019759 fix(api): declare undici for the bundled http client (#2410) 2026-09-02 19:55:11 +02:00
HampusandGitHub a74f1b0e7b fix(ci): clear knip, refresh openapi, close kv schema race (#2409) 2026-09-02 18:12:48 +02:00
HampusandGitHub 2b12f5db6c feat(voice): enable web camera background effects (#2408) 2026-09-02 17:40:13 +02:00
HampusandGitHub af4a52173c fix(voice): dispatch sourceLifecycle.removed on unbind (#2407) 2026-09-02 17:40:00 +02:00
HampusandGitHub 5bc1f21d46 fix(voice): drive call tiles from gateway voice state (#2406) 2026-09-02 17:39:48 +02:00
HampusandGitHub 917e437939 feat(voice-menus): add call controls to private call user menus (#2405) 2026-09-02 17:39:25 +02:00
HampusandGitHub 7ee4fb37d6 feat(voice): add a live input level meter to voice menus (#2404) 2026-09-02 17:39:01 +02:00
HampusandGitHub 6155eec804 feat(voice): flatten voice menus, gate ptt on a bound key (#2403) 2026-09-02 17:38:37 +02:00
HampusandGitHub 43d8637153 fix(voice): gate the camera preview and update effects in place (#2402) 2026-09-02 17:38:14 +02:00
HampusandGitHub 250db2fdab fix(voice): hide stream volume without remote share audio (#2401) 2026-09-02 17:37:52 +02:00
HampusandGitHub 7bd021cd5c feat(voice): open voice popouts in the browser (#2400) 2026-09-02 17:37:28 +02:00
HampusandGitHub adb9a689f0 feat(voice): share the voice room across popout trees (#2399) 2026-09-02 17:37:04 +02:00
HampusandGitHub d8e0c2ec20 fix(settings): stop auto-requesting devices, warn when none (#2398) 2026-09-02 17:36:49 +02:00
HampusandGitHub f98a74170a feat(settings): move macos permission review into desktop tab (#2397) 2026-09-02 17:36:26 +02:00
HampusandGitHub 17b9821879 fix(voice-menus): drive stream actions from the published source (#2396) 2026-09-02 17:36:05 +02:00
HampusandGitHub 4b5bdefcb9 fix(voice-menus): sentence-case participant menu labels (#2395) 2026-09-02 17:35:49 +02:00
HampusandGitHub 45cbabd94d fix(voice): abort screen share when its audio cannot start (#2394) 2026-09-02 17:35:26 +02:00
HampusandGitHub 8402eb53c8 feat(voice): skip the screen-share picker modal on web (#2393) 2026-09-02 17:35:15 +02:00
HampusandGitHub d04ace5789 feat(voice): redesign the screen-share source picker (#2392) 2026-09-02 17:35:01 +02:00
HampusandGitHub e94f587535 feat(voice): sequence join chimes ahead of entrance sounds (#2391) 2026-09-02 17:34:36 +02:00
HampusandGitHub e73285060e fix(voice): honour the codec preference in fallback selection (#2390) 2026-09-02 17:34:24 +02:00
HampusandGitHub f1734704ef fix(voice): guard stale screen-share negotiation and probes (#2389) 2026-09-02 17:34:10 +02:00
HampusandGitHub 59f6217267 refactor(voice): bound the screen-share codec wire formats (#2388) 2026-09-02 17:33:57 +02:00
HampusandGitHub 90f4a222b7 refactor(voice): request mic and camera permission separately (#2387) 2026-09-02 17:33:45 +02:00
HampusandGitHub 749d2091eb fix(voice): log local voice state hydration failures (#2386) 2026-09-02 17:33:32 +02:00
HampusandGitHub 8d34c6bcaa refactor(voice): make screen-share source swaps atomic (#2385) 2026-09-02 17:33:18 +02:00
HampusandGitHub 62577b25bb feat(voice): request web share audio via the browser picker (#2384) 2026-09-02 17:32:55 +02:00
HampusandGitHub 475f5a7dae fix(voice): refresh camera capture when the device changes (#2383) 2026-09-02 17:32:43 +02:00
HampusandGitHub 1772b3aad0 fix(voice): polish the stream settings menu (#2382) 2026-09-02 17:32:26 +02:00
HampusandGitHub 7263b21a06 refactor(voice): pin screen share to a fixed 7 Mbps bitrate (#2381) 2026-09-02 17:32:01 +02:00
HampusandGitHub 501adff13d refactor(voice): clamp premium video quality at read time (#2380) 2026-09-02 17:31:39 +02:00
HampusandGitHub 12c6fb7b7f feat(voice): add screen-share audio and rollback error handling (#2379) 2026-09-02 17:31:27 +02:00
HampusandGitHub 376168c922 feat(voice): add the camera background effects pipeline (#2378) 2026-09-02 17:31:04 +02:00
HampusandGitHub cadab239a2 feat(backgrounds): accept webm custom call backgrounds (#2377) 2026-09-02 17:30:50 +02:00
HampusandGitHub f57dc77c6d vendor(livekit): make local track swaps transactional (#2376) 2026-09-02 17:30:35 +02:00
HampusandGitHub 497a494c37 vendor(livekit): await setParameters in setDegradationPreference (#2375) 2026-09-02 17:30:22 +02:00
HampusandGitHub 02069e8e5d feat(voice-engine): add a sourceLifecycle.removed event (#2374) 2026-09-02 17:30:08 +02:00
HampusandGitHub 626293392c fix(ui): let callers style the audio level meter (#2373) 2026-09-02 17:29:55 +02:00
HampusandGitHub dfe42ae3e3 feat(sound): play one-shot sounds immediately and abortably (#2372) 2026-09-02 17:29:43 +02:00
HampusandGitHub 453abfa145 fix(ui): keep context menus clear of the native titlebar (#2371) 2026-09-02 17:29:29 +02:00
HampusandGitHub 8ebc9400ce fix(permissions): gate role hierarchy on known membership (#2370) 2026-09-02 17:29:06 +02:00
HampusandGitHub 1598f48edd fix(guild): invalidate member sidebar on role changes (#2369) 2026-09-02 17:28:44 +02:00
HampusandGitHub cc92f37f0c test(app): stop reading gl calls as react hooks (#2368) 2026-09-02 17:28:32 +02:00
HampusandGitHub 9322aca6cb fix(channel): restore composer draft and message focus (#2367) 2026-09-02 17:28:19 +02:00
HampusandGitHub ee8fbd6f4f fix(ui): keep the focus ring stable across refocus (#2366) 2026-09-02 17:27:57 +02:00
HampusandGitHub 1acd61a112 fix(ui): hand tooltips over between adjacent triggers (#2365) 2026-09-02 17:27:46 +02:00
HampusandGitHub e836686a71 fix(permissions): map not-determined media status to prompt (#2364) 2026-09-02 17:27:35 +02:00
HampusandGitHub ea6c417378 fix(discovery): keep category counts when a search resolves (#2363) 2026-09-02 17:27:22 +02:00
HampusandGitHub 16cc9a9e69 fix(app): clamp persisted accessibility values (#2362) 2026-09-02 17:27:10 +02:00
HampusandGitHub 6b5316fa84 fix(desktop): return a generic clipboard copy error (#2361) 2026-09-02 17:26:57 +02:00
HampusandGitHub a53f5d1289 fix(desktop): verify privileged ipc senders (#2360) 2026-09-02 17:26:45 +02:00
HampusandGitHub de2ea99928 fix(desktop): pin outbound fetches to a validated address (#2359) 2026-09-02 17:26:32 +02:00
HampusandGitHub 25f4332b9e fix(auth): guard stale submissions and rework form error mapping (#2358) 2026-09-02 17:26:18 +02:00
HampusandGitHub f703969e80 fix(auth): require a hashed poll secret for desktop handoff (#2357) 2026-09-02 17:25:54 +02:00
HampusandGitHub b82681b77a fix(auth): revoke the parsed token on logout (#2356) 2026-09-02 17:25:33 +02:00
HampusandGitHub ef248a8515 fix(platform): parse api error responses in one place (#2355) 2026-09-02 17:25:21 +02:00
HampusandGitHub 73a2345c26 fix(app-proxy): validate config, csp sources, cap resource use (#2354) 2026-09-02 17:25:10 +02:00
HampusandGitHub 9f33177eab fix(gateway): rate limit resume like identify (#2353) 2026-09-02 17:24:58 +02:00
HampusandGitHub d5a752c338 fix(gateway): only trust the client ip header when enabled (#2352) 2026-09-02 17:24:46 +02:00
HampusandGitHub 4021a2d697 fix(gateway): bound the zstd decompression window (#2351) 2026-09-02 17:24:35 +02:00
HampusandGitHub bea4a6dcbc fix(read-state): keep a failed ack dispatch from failing acks (#2350) 2026-09-02 17:24:24 +02:00
HampusandGitHub 4f48e04cad feat(api): serve well-known discovery with etag and 304 (#2349) 2026-09-02 17:24:11 +02:00
HampusandGitHub 5719dfe8a3 fix(auth): bucket phone attempt risk by v4 and v6 subnet (#2348) 2026-09-02 17:23:58 +02:00
HampusandGitHub 4fb14e85e8 fix(auth): harden login rate keys and totp reuse (#2347) 2026-09-02 17:23:47 +02:00
HampusandGitHub 1d84689b45 fix(api): validate push and domain verification targets (#2346) 2026-09-02 17:23:34 +02:00
HampusandGitHub 693aec2b4d fix(api): trust recorded upload types and bound edit sizes (#2345) 2026-09-02 17:23:23 +02:00
HampusandGitHub c79c0ee138 fix(api): bound storage listings, ranges and search paging (#2344) 2026-09-02 17:23:12 +02:00
HampusandGitHub e86e24a2db fix(captcha): drop the body-email contact policy exemption (#2343) 2026-09-02 17:23:02 +02:00
HampusandGitHub 0f7ad484ce fix(constants): add captcha, cache and feature headers (#2342) 2026-09-02 17:22:51 +02:00
HampusandGitHub bcd95b2af9 fix(http-client): validate public addresses at connect time (#2341) 2026-09-02 17:22:37 +02:00
HampusandGitHub 32dcd5ed1c chore(i18n): resync message catalogs with source (#2340) 2026-09-02 17:22:21 +02:00
HampusandGitHub 5119febb5b fix(api): send stickers through webhooks (#2338) 2026-09-02 13:55:54 +02:00
HampusandGitHub 20cdfd3009 fix(api): stop exporting unused worker heartbeat symbols (#2334) 2026-09-01 21:03:26 +02:00
HampusandGitHub 9f739427c4 fix(desktop): update rtrb past the double free advisory (#2336) 2026-09-01 21:03:19 +02:00
HampusandGitHub 0201cafd7e fix(admin): mark the crate unpublished so cargo deny passes (#2335) 2026-09-01 21:03:12 +02:00
HampusandGitHub 37f57bb29f fix(desktop): restore offline Flatpak builds (#2332) 2026-09-01 20:51:18 +02:00
HampusandGitHub ebd723679b docs(operator): refresh the bundle pin and tunnel setup (#2333) 2026-09-01 20:51:00 +02:00
HampusandGitHub 961fa1f007 fix(self-hosting): correct compose probes and origins (#2330) 2026-09-01 20:47:20 +02:00
HampusandGitHub 7900a4da0c feat(ci): pin releases to an immutable image set (#2327) 2026-09-01 20:47:20 +02:00
HampusandGitHub 8a24730884 fix(kv): align rust and typescript schema migration (#2328) 2026-09-01 20:47:19 +02:00
HampusandGitHub 1688e7dc50 fix(api): survive transient database errors in the worker (#2326) 2026-09-01 20:47:19 +02:00
HampusandGitHub aa267b54ec fix(api): dead-letter retired worker task types (#2323) 2026-09-01 20:47:19 +02:00
HampusandGitHub bc40073a02 fix(config): carry the public port into derived endpoints (#2329) 2026-09-01 20:47:18 +02:00
HampusandGitHub a93f9dd0af fix(app-proxy): separate readiness from liveness (#2322) 2026-09-01 20:47:18 +02:00
HampusandGitHub 53a9fdc4b6 fix(app-proxy): share one asset tree across architectures (#2325) 2026-09-01 20:47:17 +02:00
HampusandGitHub cef600277c fix(media-proxy): probe health with the binary not /dev/tcp (#2324) 2026-09-01 20:47:17 +02:00
HampusandGitHub bdac438329 fix(docker): emit consistent OCI metadata on every image (#2321) 2026-09-01 20:47:16 +02:00
HampusandGitHub 2d77f36a0b fix(ci): generate locale and channel files before typecheck (#2320) 2026-09-01 20:47:16 +02:00
HampusandGitHub 90aa810ce4 fix(gateway): share the relay dispatch bound across producers (#2315) 2026-09-01 04:34:41 +02:00
HampusandGitHub cf3af50464 fix(kv): bound multi key fan out by pipelining per hash slot (#2313) 2026-09-01 02:59:00 +02:00
HampusandGitHub 3b5b20c139 fix(gateway): bound relay dispatch without per-event probes (#2312) 2026-09-01 02:56:21 +02:00
HampusandGitHub 24cd163acd fix(kv): restore keyset paging for numeric key scans (#2314) 2026-09-01 02:54:10 +02:00
HampusandGitHub 49f76e5b40 fix(api): abort startup on unverifiable deletion queue state (#2311) 2026-09-01 02:45:29 +02:00
HampusandGitHub 871788f0a9 fix(gateway): reclaim ip connection counts from dead sockets (#2308) 2026-09-01 01:39:25 +02:00
HampusandGitHub 24138b70f1 fix(kv): stop multi-key commands spanning cluster slots (#2310) 2026-09-01 01:39:16 +02:00
HampusandGitHub da3332e711 fix(gateway): bound relay worker mailboxes without reordering (#2309) 2026-09-01 01:38:36 +02:00
HampusandGitHub 06e5cf2032 perf(gateway): evict presence tombstones in insertion order (#2307) 2026-09-01 01:34:57 +02:00
HampusandGitHub d4b1923c23 fix(gateway): stop presence evictions suppressing repair (#2305) 2026-09-01 01:33:13 +02:00
HampusandGitHub 42df4f6731 fix(kv): drop the row_key order probe that cliffed paged scans (#2306) 2026-09-01 01:32:33 +02:00
HampusandGitHub f1e6e94041 fix(cache): stop a timed out produce pinning its tracking entry (#2304) 2026-09-01 01:28:12 +02:00
HampusandGitHub 0cd12b2f32 test(api): build deletion queue users from the real row type (#2303) 2026-09-01 00:57:41 +02:00
HampusandGitHub 5da4d24d38 fix(kv): page scans by keyset so deletes cannot skip rows (#2302) 2026-09-01 00:29:24 +02:00
HampusandGitHub 7806d2ac02 fix(gateway): stop stale guild connect timers aborting connects (#2301) 2026-09-01 00:23:42 +02:00
HampusandGitHub 2c4d182d1f fix(gateway): keep dispatch ordered under relay backpressure (#2300) 2026-09-01 00:17:12 +02:00
HampusandGitHub dcd5f88d65 fix(gateway): stop rate limit tables dying with their creator (#2299) 2026-09-01 00:16:26 +02:00
HampusandGitHub 662f4ac93b fix(worker): stop skipped accounts starving the deletion queue (#2294) 2026-09-01 00:16:08 +02:00
HampusandGitHub a2480c6a02 fix(cache): time out a getOrSet produce that never settles (#2297) 2026-09-01 00:15:44 +02:00
HampusandGitHub c49460a44f fix(gateway): stop anti-entropy resurrecting deleted presence (#2298) 2026-09-01 00:14:32 +02:00
HampusandGitHub 6786dfe7e3 fix(gateway): stop dropping newly requested lazy ranges (#2293) 2026-09-01 00:14:24 +02:00
HampusandGitHub 7d710d881a fix(worker): lease premium reconciliation queue entries (#2296) 2026-09-01 00:14:08 +02:00
HampusandGitHub 2ea2e79f6f fix(voice): stop occupancy writes spanning kv cluster slots (#2295) 2026-09-01 00:11:29 +02:00
HampusandGitHub cd42dd8ca7 fix(api): rebuild the deletion queue under its lock (#2292) 2026-09-01 00:06:33 +02:00
HampusandGitHub f2eddeae4d fix(gateway): stop rate limit sweepers outliving their table (#2291) 2026-08-31 23:25:00 +02:00
HampusandGitHub 8e1a8fc7e3 fix(gateway): sweep stale shared ip and user rate buckets (#2290) 2026-08-31 22:53:37 +02:00
HampusandGitHub 87c08b051f fix(voice): finish the reconciliation sweep before stopping (#2289) 2026-08-31 22:38:04 +02:00
HampusandGitHub 9d95a80857 fix(worker): renew the deletion queue lock during a rebuild (#2287) 2026-08-31 22:38:00 +02:00
HampusandGitHub 9371b6d5de fix(worker): count each channel once in a bulk reindex (#2286) 2026-08-31 22:37:56 +02:00
HampusandGitHub bdcf4b25c0 chore(expressions): remove the pack residue cleanup tool (#2288) 2026-08-31 22:31:54 +02:00
HampusandGitHub 3dc344be65 fix(worker): keep attachment decay state on a stale expiry row (#2285) 2026-08-31 22:26:21 +02:00
HampusandGitHub 17ed0f70aa fix(gateway): release the user session count on a handoff fence (#2284) 2026-08-31 22:25:14 +02:00
HampusandGitHub be3e12e60d fix(gateway): clamp a heartbeat ack to the session sequence (#2283) 2026-08-31 22:23:43 +02:00
HampusandGitHub 4261cc2ea5 fix(worker): resubscribe when the job stream ends unexpectedly (#2282) 2026-08-31 22:22:14 +02:00
HampusandGitHub 88dbc27019 fix(gateway): group debounced reactions by their own message (#2281) 2026-08-31 22:21:14 +02:00
HampusandGitHub f38fc80c31 fix(gateway): clear the presence pid cache on a presence down (#2279) 2026-08-31 22:19:25 +02:00
HampusandGitHub 803fdaf443 fix(worker): stop replaying requeued asset deletions in a run (#2280) 2026-08-31 22:19:22 +02:00
HampusandGitHub 55d85db401 fix(gateway): drop the channel engine on an empty range list (#2278) 2026-08-31 22:17:19 +02:00
HampusandGitHub 7ce3d71c44 fix(gateway): stop a non-map opcode payload crashing the socket (#2277) 2026-08-31 22:14:26 +02:00
HampusandGitHub 04e150e4bf fix(gateway): keep the replay buffer across a session transfer (#2276) 2026-08-31 22:10:44 +02:00
HampusandGitHub 0f6b118921 fix(worker): catch up cron jobs missed by a delayed tick (#2275) 2026-08-31 22:06:15 +02:00
HampusandGitHub 44277e6aa2 fix(worker): drain in-flight jobs before the runner stops (#2274) 2026-08-31 22:06:12 +02:00
HampusandGitHub bb7e8cc6f1 fix(gateway): flush buffered presences in arrival order (#2273) 2026-08-31 22:04:49 +02:00
HampusandGitHub 32a64fb097 fix(cache): refcount produce tracking so deletes are not lost (#2272) 2026-08-31 22:00:48 +02:00
HampusandGitHub c4594397e7 fix(desktop): accept array-form AppRun sandbox fallback (#2271) 2026-08-31 21:42:03 +02:00
HampusandGitHub 6a188a4cdf fix(api): enforce guild bans when approving registrations (#2270) 2026-08-31 20:19:18 +02:00
HampusandGitHub 0ca0defd24 fix(desktop): keep notification sounds during fullscreen apps (#2269) 2026-08-31 19:51:42 +02:00
HampusandGitHub b0b84f9c98 fix(media-proxy): cap external streams with no declared length (#2267) 2026-08-31 19:23:48 +02:00
HampusandGitHub ef8d1225b5 refactor(api): split webhook attachment schemas (#2268) 2026-08-31 19:13:51 +02:00
HampusandGitHub 240b7e4388 feat(expressions): add an expression pack residue cleanup tool (#2265) 2026-08-31 19:06:31 +02:00
HampusandGitHub bd205d2250 fix(app-proxy): honour the shared Postgres settings (#2262) 2026-08-31 19:00:43 +02:00
HampusandGitHub e5e5bcccee docs(operator): pin self-hosting downloads to stable revision (#2266) 2026-08-31 18:57:58 +02:00
HampusandGitHub a5395b0109 fix(api): support presigned webhook attachments (#2264) 2026-08-31 18:54:25 +02:00
HampusandGitHub 09cea4394f fix(app-proxy): give each test fixture its own temp directory (#2261) 2026-08-31 18:49:30 +02:00
HampusandGitHub afeaddea22 fix(cache): do not fan a failed getOrSet out to its joiners (#2260) 2026-08-31 18:44:31 +02:00
HampusandGitHub 45f694310a fix(api): make the http header and request timeouts tunable (#2259) 2026-08-31 18:43:14 +02:00
HampusandGitHub 995f5118b2 fix(message): reap orphaned rows on the build paths (#2257) 2026-08-31 17:23:46 +02:00
HampusandGitHub 415888a615 fix(admin): stop a stale CSRF cookie wedging actions (#2258) 2026-08-31 17:23:35 +02:00
HampusandGitHub 542fb9176a fix(svc): allow disabling named Postgres prepared statements (#2256) 2026-08-31 17:19:44 +02:00
HampusandGitHub b8f8d8d859 feat(expressions): remove the unfinished packs feature (#2250) 2026-08-31 16:34:22 +02:00
HampusandGitHub 0eef611b6d test(message): pin response mapping across batch boundaries (#2255) 2026-08-31 16:24:33 +02:00
HampusandGitHub f0612ee860 fix(message): key batched message responses by message id (#2254) 2026-08-31 16:19:29 +02:00
HampusandGitHub 8c85cce75c fix(message): bound batched message response requests by size (#2252) 2026-08-31 16:13:36 +02:00
HampusandGitHub 5036ac3efa fix(api): allow disabling named Postgres prepared statements (#2251) 2026-08-31 16:10:52 +02:00
HampusandGitHub 9025e03422 feat(admin): remove the unfinished billing APIs and panel UI (#2248) 2026-08-31 15:57:11 +02:00
HampusandGitHub e82e8529bf fix(gateway): replay voice state updates after a resume (#2249) 2026-08-31 15:56:32 +02:00
HampusandGitHub eb1ed69489 chore(api): drop scheduled messages from the openapi spec (#2247) 2026-08-31 15:43:34 +02:00
HampusandGitHub e81f3f7eae fix(cache): do not resurrect a key deleted during getOrSet (#2246) 2026-08-31 15:41:05 +02:00
HampusandGitHub 4b9964bc89 fix(api): stop bounding request body receipt at the header timeout (#2245) 2026-08-31 15:36:54 +02:00
HampusandGitHub b4a2af75d0 fix(media-proxy): read content length from the response header (#2244) 2026-08-31 15:33:49 +02:00
HampusandGitHub 8c3e3285f7 fix(gateway): clamp the derived BEAM scheduler count (#2243) 2026-08-31 15:29:58 +02:00
HampusandGitHub 0a4f6ff9fb fix(test): surface docker errors when a test container fails (#2242) 2026-08-31 15:16:53 +02:00
HampusandGitHub bfa1367ca2 fix(gateway): restore erlfmt style in the presence rpc module (#2241) 2026-08-31 15:10:59 +02:00
HampusandGitHub bb81a2f165 fix(self-host): persist valkey and stop evicting durable state (#2240) 2026-08-31 14:48:58 +02:00
HampusandGitHub 7f448b1cab fix(message): always flag a message when a reaction is added (#2239) 2026-08-31 14:47:16 +02:00
HampusandGitHub 2dd35c0d6e fix(gateway): reject unknown rpc methods instead of crashing (#2238) 2026-08-31 14:34:35 +02:00
HampusandGitHub 0e73346c5f fix(svc): treat a shard overload reply as retryable backpressure (#2237) 2026-08-31 14:23:11 +02:00
HampusandGitHub 8476595507 fix(api): let node size its heap from the container limit (#2236) 2026-08-31 14:09:13 +02:00
HampusandGitHub 7b9284edb9 fix(build): allow unused patches when deploying the api subset (#2235) 2026-08-31 14:06:07 +02:00
HampusandGitHub c982b33212 fix(self-host): size memory limits and make them overridable (#2234) 2026-08-31 13:42:12 +02:00
HampusandGitHub 3c8466d714 feat(message): remove the unfinished scheduled messages feature (#2233) 2026-08-31 13:32:55 +02:00
HampusandGitHub eeea391b63 fix(kv): claim parked jobs by member instead of secondary key (#2232) 2026-08-31 13:16:52 +02:00
HampusandGitHub 5c2dca1c51 chore(workspace): tighten quality gates (#2230) 2026-08-31 04:43:20 +02:00
HampusandGitHub e6e4c6f7b5 perf(api): stop exempting self-hosted from response gating (#2228) 2026-08-31 02:04:07 +02:00
HampusandGitHub 5f6f9428ac build(api): bundle the api instead of transpiling at boot (#2227) 2026-08-31 01:26:30 +02:00
HampusandGitHub ba54b61dcf perf(guild): add a lean channel auth context rpc (#2226) 2026-08-31 01:04:55 +02:00
HampusandGitHub 8dc2bad843 perf(auth): cache auth session lookups by token hash (#2225) 2026-08-31 01:04:51 +02:00
HampusandGitHub 3594cbd5ca perf(svc): forward messages shard replies without transcoding (#2224) 2026-08-31 01:04:47 +02:00
HampusandGitHub 21b1e4e719 perf(ready): stop sending read state twice per session (#2223) 2026-08-31 01:04:43 +02:00
HampusandGitHub 50a17b6263 fix(metrics): reject non-loopback callers on metrics endpoints (#2222) 2026-08-31 00:23:00 +02:00
HampusandGitHub 0a920def2b fix(kv): mark the messages migration done instead of rescanning (#2221) 2026-08-31 00:22:56 +02:00
HampusandGitHub c4b1471923 perf(api): cache channel and guild reads for the request (#2220) 2026-08-31 00:22:52 +02:00
HampusandGitHub ab08ed0d7c chore(self-host): give every compose service a memory ceiling (#2212) 2026-08-31 00:22:48 +02:00
HampusandGitHub 34c13a747d chore(self-host): probe the api readiness during startup (#2216) 2026-08-31 00:17:09 +02:00
HampusandGitHub d559d8853d perf(gateway): size replay buffer entries once per dispatch (#2210) 2026-08-31 00:14:57 +02:00
HampusandGitHub a96d9cd075 perf(worker): skip the bunny purge cron when purging is off (#2208) 2026-08-31 00:14:39 +02:00
HampusandGitHub b163888cf3 perf(gateway): stop building discarded debug logs on fanout (#2219) 2026-08-31 00:13:27 +02:00
HampusandGitHub b07e2c397c perf(api): resolve the client ip once per request (#2218) 2026-08-31 00:13:24 +02:00
HampusandGitHub 3eeba1da2b fix(gateway): stop discarding container logs and add readiness (#2217) 2026-08-31 00:13:20 +02:00
HampusandGitHub e160b1bf07 perf(api): fast path json bodies with no large integers (#2214) 2026-08-31 00:13:17 +02:00
HampusandGitHub 1199b36d1a perf(worker): stop rereading rows in the discovery index sync (#2211) 2026-08-31 00:13:13 +02:00
HampusandGitHub 7a506478c7 perf(message): unlog bucket index writes on the read path (#2209) 2026-08-31 00:13:09 +02:00
HampusandGitHub 6faa40e0c2 fix(worker): bound the jobs stream and shed on overflow (#2204) 2026-08-31 00:13:04 +02:00
HampusandGitHub 768657d7e5 perf(worker): batch the inactivity sweep activity lookups (#2215) 2026-08-31 00:07:07 +02:00
HampusandGitHub 7157cca22f perf(worker): match the user export zip level to the guild one (#2213) 2026-08-31 00:07:03 +02:00
HampusandGitHub 7aec79d3ad perf(worker): throttle the cancel check in the domain sync (#2207) 2026-08-31 00:06:59 +02:00
HampusandGitHub 4357d5ec5d fix(search): stop leaking meilisearch task ids on the api (#2206) 2026-08-31 00:06:56 +02:00
HampusandGitHub 7c1c8b2749 perf(rate-limit): precompute bucket hash and client identifier (#2205) 2026-08-31 00:06:52 +02:00
HampusandGitHub 15656bd5c8 perf(users): read only the partial columns for partial requests (#2203) 2026-08-31 00:06:48 +02:00
HampusandGitHub c4897a7026 fix(svc): bound scylla request timeout below the rpc budget (#2202) 2026-08-31 00:06:44 +02:00
HampusandGitHub e993a47720 perf(guild): fetch guild members a thousand at a time (#2201) 2026-08-31 00:06:41 +02:00
HampusandGitHub 0d4c65ad79 perf(cassandra): skip re-registering identical select metadata (#2200) 2026-08-31 00:06:37 +02:00
HampusandGitHub f09bdb2b00 fix(cache): single-flight getOrSet and cache null results (#2199) 2026-08-31 00:06:33 +02:00
HampusandGitHub f1400ae58e fix(cassandra): shorten the read timeout below the rpc deadline (#2198) 2026-08-31 00:06:29 +02:00
HampusandGitHub b5496097d2 perf(message): reuse the resolved channel for dm send checks (#2197) 2026-08-31 00:06:25 +02:00
HampusandGitHub d5fb495e19 perf(read-state): read acked read states in one query (#2196) 2026-08-31 00:06:20 +02:00
HampusandGitHub 00e716bc3f perf(worker): skip the premium sweep on self-hosted instances (#2195) 2026-08-31 00:06:16 +02:00
HampusandGitHub ea4edd668f fix(worker): heartbeat long running jobs to hold the ack (#2194) 2026-08-31 00:06:12 +02:00
HampusandGitHub a22db125a9 perf(ready): send timing diagnostics only to staff sessions (#2190) 2026-08-30 23:50:09 +02:00
HampusandGitHub e7f68c2e20 test(message): count permission fetches instead of view checks (#2193) 2026-08-30 23:25:47 +02:00
HampusandGitHub 933b13f3fa perf(gateway): re-enable generational GC on hot processes (#2188) 2026-08-30 23:23:26 +02:00
HampusandGitHub 0be6c9c734 perf(gateway): skip permission cache rebuild on no-op updates (#2187) 2026-08-30 23:23:22 +02:00
HampusandGitHub 5aac331368 perf(gateway): skip materialising member list subscribers (#2184) 2026-08-30 23:23:18 +02:00
HampusandGitHub 57ec484626 fix(app): persist input access nagbar dismissal (#2192) 2026-08-30 23:20:51 +02:00
HampusandGitHub 9cd832bfa9 fix(app): let backspace cross a composer soft-wrap boundary (#2191) 2026-08-30 23:19:02 +02:00
HampusandGitHub 299cc40ff5 fix(gateway): split inbound and outbound rpc concurrency keys (#2186) 2026-08-30 23:16:53 +02:00
HampusandGitHub 6d305bacdf build(rust): enable fat lto and one codegen unit in release (#2185) 2026-08-30 23:16:49 +02:00
HampusandGitHub 6fd3177844 fix(auth): time-bound outbound fetches and re-key pwned cache (#2183) 2026-08-30 23:16:44 +02:00
HampusandGitHub 5586d34293 fix(app): preserve input access nagbar dismissal (#2189) 2026-08-30 23:11:57 +02:00
HampusandGitHub d43d242b16 perf(api): compile the phrase blocklist into one matcher (#2164) 2026-08-30 23:05:55 +02:00
HampusandGitHub 9f620e8c4b perf(user): prefetch user partials for list endpoints (#2182) 2026-08-30 23:05:17 +02:00
HampusandGitHub 6c9afcc734 perf(gateway): skip member list resync on inert presence deltas (#2180) 2026-08-30 23:05:13 +02:00
HampusandGitHub 43d6c85f7e perf(push): batch badge count invalidation per mention chunk (#2178) 2026-08-30 23:05:08 +02:00
HampusandGitHub 78056e0041 perf(gateway): pre-encode voice state update fanout (#2177) 2026-08-30 23:05:03 +02:00
HampusandGitHub e83a2d6aec perf(user): stop double-writing last active on every request (#2148) 2026-08-30 23:04:59 +02:00
HampusandGitHub bac06fe182 perf(gateway): restore generational GC as the vm default (#2181) 2026-08-30 23:01:25 +02:00
HampusandGitHub 8ff6518797 perf(postgres): name the fixed kv statement shapes (#2173) 2026-08-30 23:01:21 +02:00
HampusandGitHub f0e7c25e4c perf(kv): collate key columns in C and drop the duplicate index (#2162) 2026-08-30 23:01:06 +02:00
HampusandGitHub 0da94965dc perf(push): cache empty subscriptions and chunk fanout lookups (#2176) 2026-08-30 22:53:07 +02:00
HampusandGitHub d82eed16b7 perf(push): batch guild settings lookups for push eligibility (#2175) 2026-08-30 22:53:03 +02:00
HampusandGitHub b26748a2a7 fix(api): bound http server limits and shed on overload (#2170) 2026-08-30 22:53:00 +02:00
HampusandGitHub a2d7f5e8cc perf(app-proxy): serve precompressed assets and pass through (#2165) 2026-08-30 22:52:15 +02:00
HampusandGitHub 72ffa3bd9a perf(gateway): memoise the parsed internal rpc url (#2174) 2026-08-30 22:48:11 +02:00
HampusandGitHub e2fccaee74 fix(gateway): derive BEAM scheduler count from the environment (#2169) 2026-08-30 22:48:07 +02:00
HampusandGitHub e41b209cb8 perf(media-proxy): cache-probe and stream external fetches (#2168) 2026-08-30 22:48:03 +02:00
HampusandGitHub 2517caf674 fix(svc): shed overload instead of buffering router requests (#2171) 2026-08-30 22:45:50 +02:00
HampusandGitHub b9ec0d5f53 perf(gateway): avoid per-key exceptions in guild data wire (#2167) 2026-08-30 22:45:47 +02:00
HampusandGitHub 02e614632f perf(api): construct request services lazily (#2166) 2026-08-30 22:45:43 +02:00
HampusandGitHub 3ca73901c9 perf(gateway): cache zstd availability instead of reprobing it (#2160) 2026-08-30 22:45:38 +02:00
HampusandGitHub c379eed266 perf(gateway): split circuit breaker state from its window (#2154) 2026-08-30 22:45:34 +02:00
HampusandGitHub 5bac4fd719 perf(api): gate response schema revalidation to non-production (#2134) 2026-08-30 22:45:29 +02:00
HampusandGitHub dd610e4c0f fix(messages): pass message refs to the mention context helpers (#2179) 2026-08-30 22:44:12 +02:00
HampusandGitHub bf080cb001 fix(search): omit referenced message from search results (#2172) 2026-08-30 22:42:07 +02:00
HampusandGitHub 169088df26 perf(gateway): drop per-recipient mailbox probe on dispatch (#2146) 2026-08-30 22:42:03 +02:00
HampusandGitHub 4a2a29f154 perf(kv): merge row data in one statement on upsert and patch (#2161) 2026-08-30 22:37:18 +02:00
HampusandGitHub 1054962008 perf(kv): chunk oversized IN lists instead of scanning (#2163) 2026-08-30 22:36:51 +02:00
HampusandGitHub 8bc8603460 perf(message): skip redundant has_reaction writes on reactions (#2149) 2026-08-30 22:36:47 +02:00
HampusandGitHub 6538b0bfeb perf(message): skip reaction deletes for unreacted messages (#2147) 2026-08-30 22:36:44 +02:00
HampusandGitHub 9d2339bb3b perf(cassandra): memoize CQL statement metadata per query (#2155) 2026-08-30 22:34:49 +02:00
HampusandGitHub 096f38d365 perf(media-proxy): bound mime sniff scans and sniff once (#2159) 2026-08-30 22:29:00 +02:00
HampusandGitHub 1046edd903 perf(media-proxy): drop the extra HEAD on passthrough GETs (#2157) 2026-08-30 22:27:51 +02:00
HampusandGitHub cbf504dbb8 perf(api): memoise the effective bluesky oauth config (#2156) 2026-08-30 22:27:47 +02:00
HampusandGitHub 8491872908 perf(user): batch mention and saved message reads (#2139) 2026-08-30 22:27:41 +02:00
HampusandGitHub c207918e90 perf(api): fetch channel permissions in one gateway call (#2145) 2026-08-30 22:17:23 +02:00
HampusandGitHub 12717f692b chore(self-host): match shard admission to the postgres pool (#2153) 2026-08-30 22:17:12 +02:00
HampusandGitHub 36d630b37b perf(cassandra): drop allocations from the undefined param guard (#2152) 2026-08-30 22:16:26 +02:00
HampusandGitHub 5333fe7c3a perf(guild): unlog the audit log index batch (#2151) 2026-08-30 22:16:23 +02:00
HampusandGitHub efae78056e fix(worker): park far-future jobs in a KV due queue (#2144) 2026-08-30 22:16:18 +02:00
HampusandGitHub 6eca64a8f7 fix(user): stop invalidating user cache on profile reads (#2142) 2026-08-30 22:16:14 +02:00
HampusandGitHub fcb629ca06 perf(app-proxy): stream local assets instead of buffering them (#2141) 2026-08-30 22:16:10 +02:00
HampusandGitHub 289f1af253 perf(worker): skip guild settings for direct mentions (#2140) 2026-08-30 22:16:06 +02:00
HampusandGitHub 8adc3ecb0b perf(message): build pin responses in one messages round trip (#2138) 2026-08-30 22:16:02 +02:00
HampusandGitHub e328c001a1 perf(messages): prefilter mention extraction and drop a pass (#2137) 2026-08-30 22:15:58 +02:00
HampusandGitHub f796a31613 perf(worker): stop ledgering the two per-message tasks (#2136) 2026-08-30 22:15:54 +02:00
HampusandGitHub e1bab2e353 perf(message): reuse channel auth across send and edit (#2135) 2026-08-30 22:15:49 +02:00
HampusandGitHub 1c135176d2 perf(messages): stop channel history scan once the page is full (#2133) 2026-08-30 22:15:45 +02:00
HampusandGitHub 723f0d6e6e perf(logger): stop building a throwaway pino root per child (#2132) 2026-08-30 22:15:40 +02:00
HampusandGitHub e14d193b43 fix(api): order service timeouts so inner hops expire first (#2131) 2026-08-30 22:15:36 +02:00
HampusandGitHub 9d1733bdb3 perf(kv-client): use EVALSHA for rate limit scripts (#2130) 2026-08-30 22:15:32 +02:00
HampusandGitHub e06436d6f5 perf(svc): use cached prepared statements for postgres kv reads (#2129) 2026-08-30 22:15:28 +02:00
HampusandGitHub 4f67e2b362 perf(messages): overlap user partial fetch with response joins (#2128) 2026-08-30 22:15:24 +02:00
HampusandGitHub 8aa3415d73 perf(message): unlog bulk message delete batches (#2126) 2026-08-30 22:15:19 +02:00
HampusandGitHub 74f22e89ce perf(svc): reuse the decoded request instead of reparsing it (#2125) 2026-08-30 22:15:15 +02:00
HampusandGitHub 7d826d1602 perf(rpc): bound concurrency in user batch fanout handlers (#2124) 2026-08-30 22:15:11 +02:00
HampusandGitHub 8aa39bc7db perf(message): drop two reads and a write from message create (#2123) 2026-08-30 22:15:07 +02:00
HampusandGitHub 36dcf51024 fix(cassandra): bound driver in-flight requests per connection (#2122) 2026-08-30 22:15:02 +02:00
HampusandGitHub 3e74180bdc chore(self-host): budget postgres pool sizes across services (#2127) 2026-08-30 22:14:56 +02:00
HampusandGitHub 45ed740575 chore(self-host): tune bundled postgres for the shipped box (#2143) 2026-08-30 22:14:52 +02:00
HampusandGitHub 8092ad8c4d fix(media-proxy): support current FFmpeg APIs (#2158) 2026-08-30 22:08:55 +02:00
HampusandGitHub b4d9cdc584 refactor(voice): remove heartbeat and debug logging sessions (#2121) 2026-08-30 21:08:46 +02:00
HampusandGitHub 36b85512c6 chore(api): drop knip-unused Postgres KV exports (#2119) 2026-08-30 18:58:50 +02:00
HampusandGitHub 14ae64f5f3 perf(api): stop Postgres KV reads scanning whole tables (#2118) 2026-08-30 18:34:23 +02:00
M0N7Y5andGitHub 990176ac7c feat(markdown): add a binary AST envelope to the native ABI (#2117) 2026-08-30 17:47:58 +02:00
M0N7Y5andGitHub 69ef46356b feat(markdown): add a native C ABI for 64-bit FFI hosts (#2115) 2026-08-30 15:47:34 +02:00
HampusandGitHub bd88c7b04b fix(desktop): don't fail startup on inconclusive native probe (#2114) 2026-08-30 15:22:48 +02:00
HampusandGitHub 03641f622f refactor(voice): flatten participant context menu and extract stream menus (#2112) 2026-08-30 03:02:30 +02:00
HampusandGitHub 3b1eb56713 fix(voice): never auto-select AV1 or HEVC for screen sharing (#2111) 2026-08-30 02:05:21 +02:00
HampusandGitHub 059bcc6c53 chore(app): regenerate theme variable manifest for font fallbacks (#2110) 2026-08-30 01:30:00 +02:00
HampusandGitHub 82043ce2a8 fix(guild): show duplicated role in its final spot without flicker (#2109) 2026-08-30 01:25:34 +02:00
HampusandGitHub 470e752fba chore(i18n): refresh catalogs for role and permission menus (#2108) 2026-08-30 00:17:31 +02:00
HampusandGitHub 3cc7b9050c fix(app): clear stuck spellcheck reload banner (#2107) 2026-08-30 00:12:48 +02:00
HampusandGitHub b1f7c78c7e fix(app): overwrite context menu hover and delete danger item (#2106) 2026-08-30 00:12:41 +02:00
HampusandGitHub 8f20b29b16 feat(guild): duplicate role, plus delete and hover in roles sidebar (#2105) 2026-08-30 00:12:36 +02:00
HampusandGitHub 19efbd3d61 fix(app): scope the show-send-button toggle to the main composer (#2104) 2026-08-30 00:12:31 +02:00
HampusandGitHub f35c0effa2 chore(i18n): drop unused gift redemption string (#2102) 2026-08-29 23:31:28 +02:00
HampusandGitHub 8363cc0844 fix(app): send only the gift link when gifting to a friend (#2101) 2026-08-29 23:29:49 +02:00
HampusandGitHub 5a11cacbae chore(i18n): refresh catalogs for spellcheck copy (#2100) 2026-08-29 23:03:01 +02:00
HampusandGitHub 27151a9487 fix(desktop): restore Linux spellcheck, prefer OS engine (#2099) 2026-08-29 22:58:48 +02:00
HampusandGitHub c152b25deb chore(i18n): refresh catalogs for global shortcut string (#2098) 2026-08-29 22:57:18 +02:00
HampusandGitHub 04d3afaf7b fix(app): default voice shortcuts to global and add a toggle (#2097) 2026-08-29 22:46:30 +02:00
HampusandGitHub dbc63e9ef7 fix(voice): surface stream audio volume for screen-share viewers (#2095) 2026-08-29 22:05:06 +02:00
HampusandGitHub ce91ff95ab fix(app): render raw unicode emoji with OS color emoji fonts (#2094) 2026-08-29 21:09:08 +02:00
HampusandGitHub d75a29f099 feat(app): composer send-button toggle and active button hover (#2093) 2026-08-29 20:25:49 +02:00
HampusandGitHub cb889b1160 fix(api): correct apns clear payload and badge handling (#2092) 2026-08-29 20:07:18 +02:00
HampusandGitHub 8db4f5cb63 feat(app): full-Unicode font fallback with self-hosted Noto (#2091) 2026-08-29 19:27:47 +02:00
HampusandGitHub d297dc5805 feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) 2026-08-29 18:00:52 +02:00
HampusandGitHub 9b8659a40b fix(desktop): stop the updater button flickering after download (#2089) 2026-08-29 17:44:43 +02:00
HampusandGitHub 96c5db3f5d fix(markdown): open code blocks for fences after text (#2088) 2026-08-29 17:32:43 +02:00
HampusandGitHub 78e403819e fix(admin): set delete_message_seconds on ban request (#2087) 2026-08-29 16:35:40 +02:00
HampusandGitHub 805acf4e5e feat(ban): accept delete_message_seconds and app options (#2086) 2026-08-29 16:22:12 +02:00
HampusandGitHub 9f099a9127 fix(api): keep the saved placeholder on sent memes (#2085) 2026-08-29 16:04:58 +02:00
HampusandGitHub 4d15c39cd7 fix(app): give mature media blur the real media dimensions (#2084) 2026-08-29 16:04:55 +02:00
HampusandGitHub 827451d12d fix(unfurl): trust curated klipy media without rescanning (#2083) 2026-08-29 16:04:52 +02:00
HampusandGitHub 03603662c6 fix(media-proxy): require corroborating frames for nsfw (#2082) 2026-08-29 16:04:48 +02:00
HampusandGitHub 34eb10cd88 fix(markdown): only open code fences at line start (#2081) 2026-08-29 15:59:04 +02:00
HampusandGitHub 82941c08c9 fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080) 2026-08-29 15:31:42 +02:00
HampusandGitHub 8d21c97d08 fix(admin): sort session imports to satisfy rustfmt (#2079) 2026-08-29 15:05:04 +02:00
HampusandGitHub 71a56f590d fix(ci): read dependent load flags at the pe32+ offset (#2078) 2026-08-29 15:00:38 +02:00
HampusandGitHub 38297c4fe7 chore(api): drop knip-unused search and SSO exports (#2077) 2026-08-29 15:00:09 +02:00
HampusandGitHub cf7ec06d85 fix(api): enforce scoped perms, age gates, audit logs (#2076) 2026-08-29 14:55:13 +02:00
HampusandGitHub f2ea10f951 fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) 2026-08-29 14:55:09 +02:00
HampusandGitHub bbd93df239 fix(api): expire auth tokens and harden login checks (#2073) 2026-08-29 14:55:04 +02:00
HampusandGitHub 9a6ab93e01 fix(media-proxy): bound GIF decode and BMFF box walking (#2075) 2026-08-29 14:55:00 +02:00
HampusandGitHub 38eed7cce6 fix(gateway): restrict /_metrics to loopback callers (#2072) 2026-08-29 14:54:56 +02:00
HampusandGitHub 79064c3399 fix(admin): sign CSRF tokens and escape them in scripts (#2071) 2026-08-29 14:54:51 +02:00
HampusandGitHub 0496b2f530 fix(ci): reject path traversal in S3 prefix downloads (#2070) 2026-08-29 14:54:37 +02:00
HampusandGitHub 9d0be1ebd1 fix(desktop): drop game capture injection and pin dll search (#2069) 2026-08-29 14:26:28 +02:00
HampusandGitHub 9ad026b8ce fix(app): repair theme CSS sync persistence and data loss (#2067) 2026-08-29 03:37:41 +02:00
HampusandGitHub 14de5971d5 chore(api): drop unused ELEVATED_MFA_PERMISSIONS export (#2066) 2026-08-29 03:12:24 +02:00
HampusandGitHub 5474be3efa fix(api): close auth, billing and authorization bypasses (#2065) 2026-08-29 02:59:38 +02:00
fluxer-ci[bot]andGitHub 9a54bbba2d chore(i18n): update public marketing catalogs (#2064) 2026-08-29 01:52:48 +02:00
fluxer-ci[bot]andGitHub 5a0110ccc8 chore(marketing): advance pointer 0c78170 → 5908507 (#2063) 2026-08-29 01:52:44 +02:00
HampusandGitHub d032d577bf fix(app-proxy): drop leaked canary debug cert from assetlinks (#2062) 2026-08-29 01:43:26 +02:00
HampusandGitHub 243954c9c5 test(api): use a future baseline in invoice-skip premium tests (#2061) 2026-08-29 01:22:29 +02:00
HampusandGitHub ba1be73389 fix(media-proxy): cap ISO-BMFF box walker recursion depth (#2059) 2026-08-29 01:04:46 +02:00
HampusandGitHub af3ad02962 fix(voice): default noise suppression to standard 2026-08-28 20:43:22 +02:00
1322 changed files with 99368 additions and 101264 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ ARG USER_GID=1000
ARG NODE_MAJOR=24
ARG ELP_VERSION=2026-02-27
ARG PNPM_VERSION=10.29.3
ARG WASM_BINDGEN_VERSION=0.2.122
ARG WASM_BINDGEN_VERSION=0.2.123
ENV DEBIAN_FRONTEND=noninteractive
+11 -4
View File
@@ -7,10 +7,16 @@ QUICK=0
SKIP_INSTALL=0
for arg in "$@"; do
case "$arg" in
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h|--help) sed -n '2,25p' "$0"; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
--quick) QUICK=1 ;;
--skip-install) SKIP_INSTALL=1 ;;
-h | --help)
sed -n '2,25p' "$0"
exit 0
;;
*)
echo "unknown argument: $arg" >&2
exit 2
;;
esac
done
@@ -64,6 +70,7 @@ stage "app: typecheck" pnpm --filter fluxer_app typecheck
stage "app: unit tests" pnpm --filter fluxer_app exec vitest run
if [ "$QUICK" -eq 0 ]; then
stage "desktop: typecheck" pnpm --filter fluxer_desktop typecheck
stage "app: production build" pnpm --filter fluxer_app build
fi
+14 -14
View File
@@ -104,6 +104,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
@@ -115,11 +118,13 @@ jobs:
context: ${{ inputs.context }}
file: ${{ inputs.dockerfile }}
push: true
provenance: false
provenance: mode=min
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
${{ inputs.extra-build-args }}
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
@@ -185,17 +190,12 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}
VERSION: ${{ needs.meta.outputs.build_version }}
MOVING_TAGS: ${{ inputs.moving-tags }}
run: |
set -euo pipefail
tag_args=()
IFS=',' read -ra moving <<< "${MOVING_TAGS}"
for raw in "${moving[@]}"; do
tag="$(echo "$raw" | xargs)"
[ -n "$tag" ] && tag_args+=( "-t" "${IMAGE}:${tag}" )
done
if (( ${#tag_args[@]} > 0 )); then
docker buildx imagetools create "${tag_args[@]}" "${IMAGE}:${VERSION}"
fi
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component "${{ inputs.image }}"
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags "${MOVING_TAGS}"
@@ -15,6 +15,13 @@ permissions:
contents: write
packages: write
concurrency:
group: publish-fluxer-app-proxy-self-hosted
cancel-in-progress: false
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve build release
@@ -26,13 +33,209 @@ jobs:
- name: approved
run: echo "Build release approved."
build:
meta:
name: resolve metadata
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
build-version: ${{ inputs['build-version'] }}
extra-build-args: |
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
build_version: ${{ steps.vars.outputs.build_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: set variables
id: vars
run: >-
tools/ci/run.sh build-app-proxy
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
dist:
name: build the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
actions: read
contents: read
packages: write
env:
IMAGE_REPO: ghcr.io/${{ github.repository_owner }}/fluxer-app-proxy-self-hosted
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: ""
BUNDLE_LOCAL_ASSETS: "true"
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED: "false"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build the dist once and publish it as the canonical asset image
env:
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_dist
- name: generate asset manifest
run: >-
tools/ci/run.sh build-app-proxy
--step generate_asset_manifest
- name: verify every manifest asset ships in the image
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build ${{ matrix.platform }}
needs: [meta, dist]
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
permissions:
actions: read
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
with:
context: .
file: fluxer_app_proxy/Dockerfile
push: true
provenance: false
platforms: linux/${{ matrix.platform }}
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-${{ matrix.platform }}
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_APP_PROXY_TIME_FREEZE_ENABLED=false
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
merge:
name: merge multi-arch manifest
needs: [meta, build]
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-amd64
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: create and push multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted
VERSION: ${{ needs.meta.outputs.build_version }}
run: |
set -euo pipefail
docker buildx imagetools create -t "${IMAGE}:${VERSION}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: >-
tools/ci/run.sh release
publish
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
- name: Advance moving image tags
env:
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy-self-hosted
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+85 -16
View File
@@ -57,11 +57,11 @@ jobs:
--step set_metadata
--build-version "${{ inputs['build-version'] }}"
build:
name: build app-proxy (amd64)
dist:
name: build and publish the canonical asset tree
needs: meta
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 60
permissions:
actions: read
contents: read
@@ -87,17 +87,17 @@ jobs:
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image + extract assets
- name: build the dist once and publish it as the canonical asset image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-dist,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_and_extract
--step build_dist
- name: generate asset manifest
run: >-
@@ -114,9 +114,63 @@ jobs:
tools/ci/run.sh build-app-proxy
--step upload_assets
- name: verify every uploaded asset is readable
env:
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
run: >-
tools/ci/run.sh build-app-proxy
--step verify_published_assets
build:
name: build app-proxy (amd64)
needs: [meta, dist]
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
actions: read
contents: read
packages: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: prepare docker config
run: >-
tools/ci/run.sh build-app-proxy
--step prepare_docker_config
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- name: configure ghcr auth
env:
GHCR_USERNAME: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
tools/ci/run.sh build-app-proxy
--step configure_ghcr_auth
- name: build and push image
env:
BUILD_VERSION: ${{ needs.meta.outputs.build_version }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_DATE: ${{ steps.source.outputs.date }}
PUBLIC_ASSET_BASE_URL: https://fluxerstatic.com
CACHE_FROM: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64
CACHE_TO: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-amd64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
run: >-
tools/ci/run.sh build-app-proxy
--step build_image
build-arm64:
name: build app-proxy (arm64)
needs: meta
needs: [meta, dist]
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
permissions:
@@ -127,6 +181,9 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: resolve source date
id: source
run: echo "date=$(TZ=UTC git log -1 --no-show-signature --pretty=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
@@ -143,8 +200,10 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
BUNDLE_LOCAL_ASSETS=false
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:buildcache-arm64,mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
env:
@@ -155,7 +214,7 @@ jobs:
name: merge multi-arch manifest
needs: [meta, build, build-arm64]
runs-on: ubuntu-24.04
timeout-minutes: 10
timeout-minutes: 20
permissions:
contents: write
packages: write
@@ -173,6 +232,15 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: verify cross-architecture asset parity
env:
APP_PROXY_ASSETS_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
APP_PROXY_AMD64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}
APP_PROXY_ARM64_REF: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
run: >-
tools/ci/run.sh build-app-proxy
--step verify_asset_parity
- name: fuse amd64 + arm64 into a multi-arch manifest
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
@@ -212,10 +280,11 @@ jobs:
- name: Advance moving image tags
env:
IMAGE: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy
VERSION: ${{ needs.meta.outputs.build_version }}
run: >-
docker buildx imagetools create
-t "${IMAGE}:v1"
-t "${IMAGE}:latest"
"${IMAGE}:${VERSION}"
tools/ci/run.sh image-set
promote
--component fluxer-app-proxy
--build-version "${VERSION}"
--registry "ghcr.io/${{ env.GHCR_OWNER }}"
--moving-tags v1,latest
+142
View File
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
name: release image set
on:
workflow_dispatch:
inputs:
build-version:
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
type: string
required: false
default: ""
from-tag:
description: "Image tag every component is read from (v1 snapshots today's moving tags, a CalVer pins a coordinated build)"
type: string
required: false
default: "v1"
component-versions:
description: "Per-component overrides, one <image>=<version> entry per line (for example fluxer-api=2026.830.191141)"
type: string
required: false
default: ""
permissions:
actions: read
contents: write
packages: read
concurrency:
group: release-image-set
cancel-in-progress: false
defaults:
run:
shell: bash
env:
GHCR_OWNER: ${{ github.repository_owner }}
jobs:
approve:
name: approve image set release
permissions: {}
runs-on: ubuntu-24.04
environment: builds
timeout-minutes: 5
steps:
- name: approved
run: echo "Image set release approved."
manifest:
name: resolve and publish the image set
needs: approve
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
packages: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5
- uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
permission-packages: read
- name: set variables
id: vars
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
run: >-
tools/ci/run.sh resolve-calver
--github-output
- name: resolve release image set
id: resolve
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
FROM_TAG: ${{ inputs['from-tag'] }}
COMPONENT_VERSIONS: ${{ inputs['component-versions'] }}
run: |
set -euo pipefail
args=(
image-set resolve
--version "${VERSION}"
--registry "ghcr.io/${GHCR_OWNER}"
--from-tag "${FROM_TAG}"
--out-dir release-out
--github-output
)
while IFS= read -r entry; do
entry="$(echo "$entry" | xargs)"
if [ -n "$entry" ]; then
args+=( --component-version "$entry" )
fi
done <<< "${COMPONENT_VERSIONS}"
tools/ci/run.sh "${args[@]}"
- name: verify release image set
env:
VERSION: ${{ steps.vars.outputs.build_version }}
run: >-
tools/ci/run.sh image-set verify
--manifest "release-out/fluxer-release-${VERSION}.json"
- name: Publish GitHub release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
VERSION: ${{ steps.vars.outputs.build_version }}
BUNDLE_COMMIT: ${{ steps.resolve.outputs.bundle_commit }}
run: |
set -euo pipefail
if [ -z "${BUNDLE_COMMIT}" ]; then
echo "image-set resolve reported no bundle commit" >&2
exit 1
fi
gh release create "fluxer-release@${VERSION}" \
--repo fluxerapp/fluxer \
--target "${BUNDLE_COMMIT}" \
--title "fluxer-release ${VERSION}" \
--latest=true \
--notes "Immutable image set for ${VERSION}. Every image in the set contains ${BUNDLE_COMMIT}, the commit this tag points at, so the bundle here is never newer than the images. Pin with: docker compose -f docker-compose.yml -f fluxer-release-${VERSION}.yml up -d" \
"release-out/fluxer-release-${VERSION}.json" \
"release-out/fluxer-release-${VERSION}.yml"
+35 -12
View File
@@ -153,7 +153,7 @@ jobs:
rust:
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -178,20 +178,40 @@ jobs:
with:
workspaces: |
. -> target
fluxer_desktop/native/rust -> target
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Install cargo-deny
run: cargo install cargo-deny --version 0.19.6 --locked
- name: Check Rust dependencies
run: cargo deny --locked check -D warnings
- name: Check desktop native dependencies
run: tools/ci/check-desktop-native-workspaces.sh dependencies
- name: Install native dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config \
build-essential \
libcurl4-openssl-dev \
libvips-dev \
binutils \
clang \
cmake \
libavfilter-dev \
libclang-dev \
libcurl4-openssl-dev \
libfido2-dev \
libheif-dev \
libwebp-dev
libpipewire-0.3-dev \
libspa-0.2-dev \
libssl-dev \
libudev-dev \
libvips-dev \
libwebp-dev \
meson \
ninja-build \
pkg-config \
zlib1g-dev
- name: Install Node.js dependencies
run: pnpm --filter fluxer_admin install
@@ -199,17 +219,20 @@ jobs:
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check formatting (desktop native)
run: cargo fmt --manifest-path fluxer_desktop/native/rust/Cargo.toml --all -- --check
- name: Check formatting (desktop native workspaces)
run: tools/ci/check-desktop-native-workspaces.sh fmt
- name: Clippy (warnings as errors)
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- name: Clippy (desktop native workspaces on Linux, warnings as errors)
run: tools/ci/check-desktop-native-workspaces.sh clippy
- name: Run tests
run: cargo test --workspace
run: cargo test --workspace --all-features --locked
- name: Run desktop native tests
run: cargo test --manifest-path fluxer_desktop/native/rust/Cargo.toml
- name: Run desktop native workspace tests on Linux
run: tools/ci/check-desktop-native-workspaces.sh test
gateway:
runs-on: ubuntu-24.04
Generated
+80 -367
View File
@@ -49,14 +49,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.2"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser 0.35.0",
"html5ever 0.35.0",
"cssparser",
"html5ever",
"maplit",
"tendril 0.4.3",
"url",
]
@@ -127,9 +126,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arc-swap"
@@ -182,7 +181,7 @@ dependencies = [
"ring",
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki",
"serde",
"serde_json",
"serde_nanos",
@@ -190,7 +189,7 @@ dependencies = [
"thiserror",
"time",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-stream",
"tokio-util",
"tokio-websockets",
@@ -528,23 +527,17 @@ dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
"aws-smithy-types",
"h2 0.3.27",
"h2 0.4.14",
"http 0.2.12",
"h2",
"http 1.4.2",
"http-body 0.4.6",
"hyper 0.14.32",
"hyper 1.10.1",
"hyper-rustls 0.24.2",
"hyper-rustls 0.27.9",
"hyper",
"hyper-rustls",
"hyper-util",
"pin-project-lite",
"rustls 0.21.12",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tower",
"tracing",
]
@@ -709,7 +702,7 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper",
"hyper-util",
"itoa",
"matchit",
@@ -933,9 +926,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -1218,9 +1211,9 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
@@ -1268,42 +1261,19 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
dependencies = [
"cssparser-macros 0.6.1",
"dtoa-short",
"itoa",
"phf 0.11.3",
"smallvec",
]
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"cssparser-macros 0.7.0",
"cssparser-macros",
"dtoa-short",
"itoa",
"phf 0.13.1",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "cssparser-macros"
version = "0.7.0"
@@ -1652,7 +1622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -1779,7 +1749,7 @@ dependencies = [
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper 1.10.1",
"hyper",
"hyper-util",
"image",
"libc",
@@ -1923,8 +1893,7 @@ dependencies = [
"libc",
"moka",
"rmp-serde",
"rustls 0.23.40",
"rustls-pemfile",
"rustls",
"scylla",
"serde",
"serde_json",
@@ -1989,6 +1958,7 @@ dependencies = [
"base64",
"chrono",
"cookie",
"fluxer_common",
"hmac 0.13.0",
"maud",
"openapiv3",
@@ -2029,6 +1999,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-util",
"tower",
"tower-http",
"tracing",
@@ -2100,16 +2071,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.32"
@@ -2282,28 +2243,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.3.27"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http 0.2.12",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "h2"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -2399,17 +2341,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "html5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
"markup5ever 0.35.0",
"match_token",
]
[[package]]
name = "html5ever"
version = "0.39.0"
@@ -2417,7 +2348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever 0.39.0",
"markup5ever",
]
[[package]]
@@ -2496,30 +2427,6 @@ dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper"
version = "1.10.1"
@@ -2530,7 +2437,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2",
"http 1.4.2",
"http-body 1.0.1",
"httparse",
@@ -2542,21 +2449,6 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"log",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
@@ -2564,12 +2456,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.2",
"hyper 1.10.1",
"hyper",
"hyper-util",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tower-service",
]
@@ -2585,12 +2477,12 @@ dependencies = [
"futures-util",
"http 1.4.2",
"http-body 1.0.1",
"hyper 1.10.1",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -2984,29 +2876,12 @@ dependencies = [
"twox-hash",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
dependencies = [
"log",
"tendril 0.4.3",
"web_atoms 0.1.3",
]
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -3014,19 +2889,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril 0.5.0",
"web_atoms 0.2.4",
]
[[package]]
name = "match_token"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn",
"tendril",
"web_atoms",
]
[[package]]
@@ -3191,7 +3055,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3362,55 +3226,25 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.6",
"phf_generator",
"phf_shared",
]
[[package]]
@@ -3420,20 +3254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
"phf_shared",
]
[[package]]
@@ -3442,22 +3263,13 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -3758,8 +3570,8 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.40",
"socket2 0.6.3",
"rustls",
"socket2 0.5.10",
"thiserror",
"tokio",
"tracing",
@@ -3779,7 +3591,7 @@ dependencies = [
"rand 0.9.4",
"ring",
"rustc-hash",
"rustls 0.23.40",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
@@ -3797,7 +3609,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
]
@@ -4015,15 +3827,15 @@ dependencies = [
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"hyper 1.10.1",
"hyper-rustls 0.27.9",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.40",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
@@ -4031,7 +3843,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -4068,7 +3880,7 @@ dependencies = [
"futures",
"getrandom 0.2.17",
"http 1.4.2",
"hyper 1.10.1",
"hyper",
"reqwest",
"reqwest-middleware",
"retry-policies",
@@ -4155,19 +3967,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki 0.101.7",
"sct",
"windows-sys 0.52.0",
]
[[package]]
@@ -4181,7 +3981,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"rustls-webpki",
"subtle",
"zeroize",
]
@@ -4198,15 +3998,6 @@ dependencies = [
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -4228,14 +4019,14 @@ dependencies = [
"jni",
"log",
"once_cell",
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki 0.103.13",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4244,16 +4035,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -4346,23 +4127,13 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
"cssparser 0.37.0",
"cssparser",
"ego-tree",
"getopts",
"html5ever 0.39.0",
"html5ever",
"precomputed-hash",
"selectors",
"tendril 0.5.0",
]
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
"tendril",
]
[[package]]
@@ -4466,12 +4237,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
"cssparser 0.37.0",
"cssparser",
"derive_more",
"log",
"new_debug_unreachable",
"phf 0.13.1",
"phf_codegen 0.13.1",
"phf",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
"servo_arc",
@@ -4765,9 +4536,9 @@ dependencies = [
[[package]]
name = "spin"
version = "0.10.0"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
[[package]]
name = "spki"
@@ -4785,19 +4556,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.11.3",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache"
version = "0.9.0"
@@ -4806,30 +4564,18 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared 0.13.1",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator 0.13.1",
"phf_shared 0.13.1",
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
@@ -4904,18 +4650,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
"windows-sys 0.52.0",
]
[[package]]
@@ -5086,7 +4821,7 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
"phf 0.13.1",
"phf",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
@@ -5103,32 +4838,22 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb"
dependencies = [
"rustls 0.23.40",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tokio-postgres",
"tokio-rustls 0.26.4",
"tokio-rustls",
"x509-cert",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.40",
"rustls",
"tokio",
]
@@ -5172,7 +4897,7 @@ dependencies = [
"ring",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tokio-rustls",
"tokio-util",
"webpki-roots 0.26.11",
]
@@ -5713,28 +5438,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf 0.11.3",
"phf_codegen 0.11.3",
"string_cache 0.8.9",
"string_cache_codegen 0.5.4",
]
[[package]]
name = "web_atoms"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
dependencies = [
"phf 0.13.1",
"phf_codegen 0.13.1",
"string_cache 0.9.0",
"string_cache_codegen 0.6.1",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
@@ -5805,7 +5518,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
+5
View File
@@ -25,3 +25,8 @@ resolver = "2"
[workspace.package]
edition = "2024"
license = "AGPL-3.0-or-later"
[profile.release]
lto = "fat"
codegen-units = 1
strip = "symbols"
+1 -1
View File
@@ -20,7 +20,7 @@
"bracketSpacing": false,
"bracketSameLine": false
},
"globals": ["React"]
"globals": ["React", "__webpack_base_uri__"]
},
"json": {
"formatter": {
-1
View File
@@ -43,7 +43,6 @@ FLUXER_SVC_NATS_URL=nats://nats:4222
FLUXER_SVC_SHARD_COUNT=1
FLUXER_SVC_CACHE_TTL_MS=30000
FLUXER_SVC_CACHE_HARD_TTL_MS=600000
FLUXER_SVC_MAX_CONCURRENT_REQUESTS=64
FLUXER_S3_ENDPOINT=http://127.0.0.1:8333
FLUXER_S3_PUBLIC_ENDPOINT=http://localhost:8088
+35 -14
View File
@@ -1,13 +1,9 @@
# cargo-deny configuration for the Fluxer workspace.
#
# Applies to the root workspace (Cargo.toml at the repo root) AND to every
# per-addon crate under fluxer_desktop/native/* (each addon has its own
# [workspace], so we invoke cargo-deny with --config pointing here).
#
# Used by the native desktop security gate in CI.
# Applies to the root workspace (Cargo.toml at the repo root).
[graph]
all-features = false
all-features = true
no-default-features = false
[output]
@@ -44,13 +40,11 @@ allow = [
"BSD-3-Clause",
"ISC",
"MPL-2.0",
"Unicode-DFS-2016",
"Unicode-3.0",
"Zlib",
"CC0-1.0",
"AGPL-3.0-or-later",
"BSL-1.0",
"OpenSSL",
"CDLA-Permissive-2.0",
]
# Explicitly deny GPL-only / strong-copyleft licenses that don't compose with
@@ -72,21 +66,48 @@ license-files = [
[bans]
multiple-versions = "warn"
wildcards = "deny"
# Per-addon crates path-depend on ../rust (the shared `fluxer_desktop_native`
# crate) without a version. cargo-deny flags that as a wildcard; we allow it
# because path deps can't realistically pin a SemVer range, and this only
# affects intra-repo workspace links (registry wildcards remain denied).
# Internal workspace crates use path dependencies without registry versions.
# Registry wildcards remain denied.
allow-wildcard-paths = true
highlight = "all"
workspace-default-features = "allow"
external-default-features = "allow"
# Keep desktop packaging and native addons away from the obsolete libfuse2 stack.
# Keep workspace artifacts away from the obsolete libfuse2 stack.
# AppImage packaging must use the static electron-builder runtime instead.
deny = [
{ crate = "fuse", reason = "libfuse2-based Rust wrapper; use a maintained FUSE3-native crate only if Fluxer ever needs FUSE directly" },
{ crate = "fuse-sys", reason = "libfuse2 FFI crate; Fluxer AppImages must not reintroduce libfuse2 through native Rust dependencies" },
]
skip = []
skip = [
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older crypto API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior hashbrown API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior randomness API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older digest API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older socket API" },
{ crate = "[email protected]+wasi-snapshot-preview1", reason = "transitive dependency requires the legacy WASI API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the prior Windows API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older WASI binding API" },
]
skip-tree = []
# ---------------------------------------------------------------------------
+113
View File
@@ -1,8 +1,41 @@
FLUXER_DOMAIN=chat.example.com
FLUXER_PUBLIC_SCHEME=https
FLUXER_PUBLIC_PORT=443
FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}
FLUXER_CADDY_SITE_ADDRESS=chat.example.com
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
# origin written with a default port never matches a browser Origin header.
# Serving on any other port means setting all three, plus the published port
# below, and pointing FLUXER_CADDY_SITE_ADDRESS at the same scheme and host.
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
# stay below the two values it reads. Above them it silently expands to a bare
# host with a trailing colon.
#FLUXER_PUBLIC_SCHEME=http
#FLUXER_PUBLIC_PORT=19080
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
#FLUXER_HTTP_PORT=19080
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
# the container, so change only these when something else already owns the
# standard ports or another proxy sits in front. Both take an optional bind
# address in front of the port, and 127.0.0.1 keeps the publish off every
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
# together, because HTTP/3 needs both on the same port.
#FLUXER_HTTP_PORT=80
#FLUXER_HTTPS_PORT=443
#FLUXER_HTTP_PORT=127.0.0.1:80
#FLUXER_HTTPS_PORT=127.0.0.1:443
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT
# still moves that one publish. Set the line below and plain docker compose
# commands pick the file up, or add it to your own -f flags if you pass any. The
# file uses the !override tag, which needs Compose 2.24.4 or newer.
#COMPOSE_FILE=docker-compose.yml:tunnel.compose.yml
FLUXER_REGISTRY_OWNER=fluxerapp
FLUXER_REGISTRY=ghcr.io/${FLUXER_REGISTRY_OWNER}
FLUXER_IMAGE_TAG=v1
@@ -30,6 +63,7 @@ [email protected]
#FLUXER_PASSKEY_RP_ID=chat.example.com
#FLUXER_PASSKEY_RP_NAME=Fluxer
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=http://chat.example.com:19080
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
# only when a browser must reach an origin the defaults do not cover, such as a
@@ -49,6 +83,20 @@ [email protected]
LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME
# Ports LiveKit publishes on the host for voice and video media. They take the
# same optional bind address as the Caddy ports above. This media does not pass
# through Caddy or through a tunnel, so it needs these ports reachable from
# clients. LiveKit advertises the port numbers from livekit.yaml, so publishing
# them on different host ports means changing that file too.
#FLUXER_LIVEKIT_TCP_PORT=7881
#FLUXER_LIVEKIT_UDP_PORT=7882
# The voice server URL clients connect to. It defaults to FLUXER_PUBLIC_ORIGIN
# plus /livekit, which the bundled Caddy proxies to the LiveKit container. Set
# it only when LiveKit lives on its own host, and add that origin to
# FLUXER_CSP_EXTRA_CONNECT_SRC when you do.
#FLUXER_LIVEKIT_URL=wss://voice.example.com
FLUXER_KLIPY_API_KEY=
FLUXER_EMAIL_ENABLED=false
@@ -65,3 +113,68 @@ FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_CAPTCHA_ENABLED=false
FLUXER_CAPTCHA_PROVIDER=none
FLUXER_DISCOVERY_ENABLED=true
# Container memory. Every service limit and reservation below has a default that
# assumes a host with at least 16 GB of RAM. Limits are per-container ceilings, so
# their sum may exceed host RAM; the reservations are what protect the services
# whose death takes the whole instance down. Lower these on a smaller host.
#FLUXER_POSTGRES_MEMORY_LIMIT=5gb
#FLUXER_POSTGRES_MEMORY_RESERVATION=3gb
#FLUXER_API_MEMORY_LIMIT=2560mb
#FLUXER_API_MEMORY_RESERVATION=1gb
#FLUXER_WORKER_MEMORY_LIMIT=2560mb
#FLUXER_WORKER_MEMORY_RESERVATION=1gb
#FLUXER_GATEWAY_MEMORY_LIMIT=1gb
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
# Node sizes its own heap from the container memory limit by default, at roughly
# 55 percent of it, which always leaves room for the buffers and stacks that live
# outside the heap. Leave these unset unless you have a reason to pin the value.
# Any value set here must stay well below the container limit above: a heap ceiling
# above the container limit makes the kernel OOM-kill the container (exit 137, no
# diagnostics) instead of Node reporting a JavaScript heap out of memory error.
#FLUXER_API_NODE_HEAP_MB=1792
#FLUXER_WORKER_NODE_HEAP_MB=1792
# Bundled Postgres tuning. Keep these consistent with FLUXER_POSTGRES_MEMORY_LIMIT:
# budget roughly shared_buffers + (server max_connections x 12 MB) +
# (3 x autovacuum_work_mem) + 300 MB for page cache and WAL. Note this is the
# server setting, distinct from the per-service FLUXER_POSTGRES_MAX_CONNECTIONS
# pool sizes used by the api, worker, app-proxy and shards.
#FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS=150
#FLUXER_POSTGRES_SHARED_BUFFERS=512MB
#FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE=2GB
#FLUXER_POSTGRES_WORK_MEM=8MB
# The bundled Valkey holds durable state as well as cache: the bulk message
# deletion queue, the account deletion queue and every distributed lock, none of
# which carry an expiry. It therefore runs with an append-only file on a named
# volume and with noeviction, so an over-limit write fails loudly instead of
# silently deleting queued work. Only change the policy if you have moved that
# durable state elsewhere.
#FLUXER_VALKEY_MAXMEMORY=192mb
#FLUXER_VALKEY_MAXMEMORY_POLICY=noeviction
# The gateway derives its BEAM scheduler count from the container CPU quota,
# clamped to this range. The floor matters: a single scheduler lets one blocking
# operation stall every websocket on the node. The ceiling stops a large host
# from starting far more schedulers than the container can actually use.
#FLUXER_ERLANG_SCHEDULERS_MIN=2
#FLUXER_ERLANG_SCHEDULERS_MAX=16
# The api and the Rust services name their fixed Postgres statement shapes so the
# server can reuse their plans. Named prepared statements require a session that
# outlives the transaction, so set this to false if you put a transaction-pooling
# connection pooler such as PgBouncer in front of Postgres. One setting governs
# every service. The bundled compose talks to Postgres directly, where naming is
# a win and the default is correct.
#FLUXER_POSTGRES_PREPARED_STATEMENTS=true
# The api bounds how long a client may take to send a request. The header timeout
# covers the request line and headers only, while the request timeout covers the
# whole exchange, so a slow uploader is bounded by the second value and not by
# the first. Raise both if you front large uploads or serve clients on high
# latency links. The header timeout is clamped down to the request timeout, so
# raising it alone does nothing. Both are milliseconds, between 1000 and 3600000.
#FLUXER_API_HEADERS_TIMEOUT_MS=30000
#FLUXER_API_REQUEST_TIMEOUT_MS=120000
+296 -58
View File
@@ -1,6 +1,17 @@
name: fluxer
x-fluxer-postgres-env: &fluxer-postgres-env
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
x-fluxer-env: &fluxer-env
<<: *fluxer-postgres-env
FLUXER_ENV: production
NODE_ENV: production
FLUXER_SELF_HOSTED: "true"
@@ -9,14 +20,8 @@ x-fluxer-env: &fluxer-env
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
FLUXER_DATABASE_BACKEND: postgres
FLUXER_POSTGRES_HOST: postgres
FLUXER_POSTGRES_PORT: "5432"
FLUXER_POSTGRES_DATABASE: fluxer
FLUXER_POSTGRES_USERNAME: fluxer
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
FLUXER_POSTGRES_SSL: "false"
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
FLUXER_KV_URL: redis://valkey:6379/0
FLUXER_NATS_URL: nats://nats:4222
@@ -50,6 +55,7 @@ x-fluxer-env: &fluxer-env
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
@@ -80,7 +86,7 @@ x-fluxer-env: &fluxer-env
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
@@ -90,36 +96,89 @@ x-fluxer-env: &fluxer-env
FLUXER_INTERNAL_API_ENDPOINT: http://api:8080
FLUXER_INTERNAL_GATEWAY_ENDPOINT: http://gateway:8080
FLUXER_INTERNAL_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_MARKETING_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
x-fluxer-service: &fluxer-service
restart: unless-stopped
networks: [fluxer]
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8090 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
services:
caddy:
image: caddy:2.10-alpine
deploy:
resources:
limits:
memory: ${FLUXER_CADDY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
ports:
- "80:80"
- "443:443"
- "443:443/udp"
- "${FLUXER_HTTP_PORT:-80}:80"
- "${FLUXER_HTTPS_PORT:-443}:443"
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
environment:
FLUXER_CADDY_SITE_ADDRESS: ${FLUXER_CADDY_SITE_ADDRESS:?set FLUXER_CADDY_SITE_ADDRESS in .env}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on: [api, gateway, media-proxy, static-proxy, admin]
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:2019/config/"]
interval: 10s
timeout: 5s
retries: 10
depends_on:
api: {condition: service_started}
gateway: {condition: service_healthy}
media-proxy: {condition: service_started}
static-proxy: {condition: service_started}
admin: {condition: service_started}
postgres:
image: postgres:16-alpine
deploy:
resources:
limits:
memory: ${FLUXER_POSTGRES_MEMORY_LIMIT:-5gb}
reservations:
memory: ${FLUXER_POSTGRES_MEMORY_RESERVATION:-3gb}
restart: unless-stopped
networks: [fluxer]
command: >
postgres
-c max_connections=${FLUXER_POSTGRES_SERVER_MAX_CONNECTIONS:-150}
-c shared_buffers=${FLUXER_POSTGRES_SHARED_BUFFERS:-512MB}
-c effective_cache_size=${FLUXER_POSTGRES_EFFECTIVE_CACHE_SIZE:-2GB}
-c work_mem=${FLUXER_POSTGRES_WORK_MEM:-8MB}
-c maintenance_work_mem=256MB
-c autovacuum_work_mem=128MB
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c default_statistics_target=200
-c jit=off
-c min_wal_size=512MB
-c max_wal_size=2GB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c wal_compression=zstd
-c bgwriter_delay=50ms
-c bgwriter_lru_maxpages=1000
-c autovacuum_vacuum_scale_factor=0.05
-c autovacuum_analyze_scale_factor=0.02
-c autovacuum_vacuum_cost_limit=2000
-c track_io_timing=on
-c shared_preload_libraries=pg_stat_statements
shm_size: 256mb
environment:
POSTGRES_DB: fluxer
POSTGRES_USER: fluxer
@@ -134,9 +193,17 @@ services:
valkey:
image: valkey/valkey:8.1-alpine
deploy:
resources:
limits:
memory: ${FLUXER_VALKEY_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["valkey-server", "--save", "", "--appendonly", "no"]
command: ["valkey-server", "--appendonly", "yes", "--appendfsync", "everysec", "--dir", "/data",
"--maxmemory", "${FLUXER_VALKEY_MAXMEMORY:-192mb}",
"--maxmemory-policy", "${FLUXER_VALKEY_MAXMEMORY_POLICY:-noeviction}"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
@@ -145,35 +212,68 @@ services:
nats:
image: nats:2.14-alpine
deploy:
resources:
limits:
memory: ${FLUXER_NATS_MEMORY_LIMIT:-256mb}
restart: unless-stopped
networks: [fluxer]
command: ["-js", "-sd", "/data", "-m", "8222"]
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8222/healthz"]
interval: 10s
timeout: 5s
retries: 10
meilisearch:
image: getmeili/meilisearch:v1.12
deploy:
resources:
limits:
memory: ${FLUXER_MEILISEARCH_MEMORY_LIMIT:-768mb}
restart: unless-stopped
networks: [fluxer]
environment:
MEILI_ENV: production
MEILI_NO_ANALYTICS: "true"
MEILI_MAX_INDEXING_MEMORY: 384mb
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
volumes:
- meilisearch-data:/meili_data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7700/health"]
interval: 10s
timeout: 5s
retries: 10
seaweedfs:
image: chrislusf/seaweedfs:4.34
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["server", "-s3", "-dir=/data"]
volumes:
- seaweedfs-data:/data
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8333/"]
interval: 10s
timeout: 5s
retries: 20
seaweedfs-init:
image: chrislusf/seaweedfs:4.34
deploy:
resources:
limits:
memory: ${FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT:-128mb}
networks: [fluxer]
depends_on: [seaweedfs]
depends_on:
seaweedfs: {condition: service_healthy}
restart: "no"
entrypoint:
- /bin/sh
@@ -205,6 +305,10 @@ services:
livekit:
image: livekit/livekit-server:v1.12.0
deploy:
resources:
limits:
memory: ${FLUXER_LIVEKIT_MEMORY_LIMIT:-512mb}
restart: unless-stopped
networks: [fluxer]
command: ["--config", "/etc/livekit.yaml"]
@@ -215,95 +319,152 @@ services:
ports:
- "${FLUXER_LIVEKIT_TCP_PORT:-7881}:7881"
- "${FLUXER_LIVEKIT_UDP_PORT:-7882}:7882/udp"
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:7880/"]
interval: 10s
timeout: 5s
retries: 10
api:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_API_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_API_MEMORY_RESERVATION:-1gb}
environment:
<<: *fluxer-env
FLUXER_API_PORT: "8080"
NODE_OPTIONS: --enable-source-maps${FLUXER_API_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_API_NODE_HEAP_MB}
FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8080/_health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_started}
meilisearch: {condition: service_started}
nats: {condition: service_healthy}
meilisearch: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
gifs: {condition: service_started}
gifs-shard: {condition: service_started}
snowflakes: {condition: service_started}
snowflakes-shard: {condition: service_started}
messages: {condition: service_started}
messages-shard: {condition: service_started}
users: {condition: service_started}
users-shard: {condition: service_started}
gifs: {condition: service_healthy}
gifs-shard: {condition: service_healthy}
snowflakes: {condition: service_healthy}
snowflakes-shard: {condition: service_healthy}
messages: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users: {condition: service_healthy}
users-shard: {condition: service_healthy}
worker:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-api:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_WORKER_MEMORY_LIMIT:-2560mb}
reservations:
memory: ${FLUXER_WORKER_MEMORY_RESERVATION:-1gb}
working_dir: /usr/src/app/fluxer_api
command: ["./node_modules/.bin/tsx", "src/WorkerEntrypoint.ts"]
command: ["node", "dist/WorkerEntrypoint.js"]
environment:
<<: *fluxer-env
NODE_OPTIONS: --enable-source-maps${FLUXER_WORKER_NODE_HEAP_MB:+ --max-old-space-size=$FLUXER_WORKER_NODE_HEAP_MB}
FLUXER_API_WORKER_MODE: all_lanes
FLUXER_API_WORKER_ENABLE_CRON_SCHEDULER: "true"
FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION: "true"
FLUXER_POSTGRES_MAX_CONNECTIONS: "25"
healthcheck:
test: ["CMD", "node", "-e", "const age=Date.now()-require('node:fs').statSync('/tmp/fluxer-worker-heartbeat').mtimeMs;if(age>30000){console.error('worker heartbeat is '+Math.round(age)+'ms old');process.exit(1)}"]
interval: 10s
timeout: 5s
retries: 3
start_period: 90s
start_interval: 1s
depends_on:
postgres: {condition: service_healthy}
valkey: {condition: service_healthy}
nats: {condition: service_started}
nats: {condition: service_healthy}
seaweedfs-init: {condition: service_completed_successfully}
snowflakes-shard: {condition: service_started}
messages-shard: {condition: service_started}
users-shard: {condition: service_started}
snowflakes-shard: {condition: service_healthy}
messages-shard: {condition: service_healthy}
users-shard: {condition: service_healthy}
gateway:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gateway:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GATEWAY_MEMORY_LIMIT:-1gb}
reservations:
memory: ${FLUXER_GATEWAY_MEMORY_RESERVATION:-384mb}
environment:
<<: *fluxer-env
FLUXER_GATEWAY_PORT: "8080"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_GATEWAY_LOGGER_LEVEL: info
healthcheck:
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/_health/ready"]
interval: 10s
timeout: 5s
retries: 30
start_period: 90s
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
valkey: {condition: service_healthy}
media-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-media-proxy:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MEDIA_PROXY_MEMORY_LIMIT:-512mb}
environment:
<<: *fluxer-env
FLUXER_MEDIA_PROXY_HOST: 0.0.0.0
FLUXER_MEDIA_PROXY_PORT: "8080"
FLUXER_MEDIA_PROXY_MODE: upload
FLUXER_MEDIA_PROXY_STORAGE_BACKEND: s3
healthcheck:
disable: true
depends_on:
seaweedfs-init: {condition: service_completed_successfully}
nats: {condition: service_started}
nats: {condition: service_healthy}
static-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-static:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_STATIC_PROXY_MEMORY_LIMIT:-256mb}
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/avatars/0.png"]
interval: 10s
timeout: 5s
retries: 10
app-proxy:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-app-proxy-self-hosted:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_APP_PROXY_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-postgres-env
FLUXER_APP_PROXY_HOST: 0.0.0.0
FLUXER_APP_PROXY_PORT: "8080"
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
@@ -315,126 +476,202 @@ services:
FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-}
FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-}
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
FLUXER_POSTGRES_MAX_CONNECTIONS: "5"
depends_on:
api: {condition: service_healthy}
caddy: {condition: service_started}
caddy: {condition: service_healthy}
postgres: {condition: service_healthy}
snowflakes:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_SNOWFLAKES_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: router
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
snowflakes-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-snowflakes:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_SNOWFLAKES_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: snowflakes
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
users:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_USERS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
users-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-users:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_USERS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
gifs:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GIFS_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
gifs-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-gifs:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_GIFS_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: gifs
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
messages:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MESSAGES_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: router
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
messages-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-messages:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_MESSAGES_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_NAME: messages
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "20"
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
postgres: {condition: service_healthy}
unfurl:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_UNFURL_MEMORY_LIMIT:-128mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: router
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
unfurl-shard:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-unfurl:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_UNFURL_SHARD_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_started}
nats: {condition: service_healthy}
admin:
<<: *fluxer-service
image: ${FLUXER_REGISTRY:-ghcr.io/${FLUXER_REGISTRY_OWNER:-fluxerapp}}/fluxer-admin:${FLUXER_IMAGE_TAG:-v1}
deploy:
resources:
limits:
memory: ${FLUXER_ADMIN_MEMORY_LIMIT:-256mb}
environment:
<<: *fluxer-env
FLUXER_ADMIN_HOST: 0.0.0.0
FLUXER_ADMIN_PORT: "8080"
FLUXER_ADMIN_BASE_PATH: /admin
FLUXER_API_ENDPOINT: http://api:8080
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/admin/oauth2_callback
FLUXER_ADMIN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin
FLUXER_APP_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/media
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
FLUXER_ADMIN_OAUTH_REDIRECT_URI: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/admin/oauth2_callback
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /_health HTTP/1.0\\r\\n\\r\\n' >&3 && head -n 1 <&3 | grep -q ' 200 '"]
interval: 10s
timeout: 5s
retries: 30
start_period: 60s
start_interval: 1s
depends_on:
api: {condition: service_healthy}
@@ -446,6 +683,7 @@ volumes:
caddy-data:
caddy-config:
postgres-data:
valkey-data:
nats-data:
meilisearch-data:
seaweedfs-data:
+4
View File
@@ -0,0 +1,4 @@
services:
caddy:
ports: !override
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
+3 -1
View File
@@ -3,14 +3,16 @@ name = "fluxer_admin"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
build = "build.rs"
[dependencies]
anyhow = "1.0.102"
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.22.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.1"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
rand = "0.10"
+21 -1
View File
@@ -24,6 +24,7 @@ RUN TAILWIND_OXIDE_VERSION="4.2.1" \
COPY Cargo.lock Cargo.lock
COPY fluxer_admin fluxer_admin
COPY fluxer_common fluxer_common
COPY packages/fonts/manifest.json packages/fonts/manifest.json
COPY packages/fonts/NOTICE.md packages/fonts/NOTICE.md
COPY packages/fonts/LICENSE-IBM-PLEX.txt packages/fonts/LICENSE-IBM-PLEX.txt
@@ -31,12 +32,17 @@ COPY packages/fonts/files/FluxerSans packages/fonts/files/FluxerSans
COPY packages/fonts/files/FluxerMono packages/fonts/files/FluxerMono
RUN printf '%s\n' \
'[workspace]' \
'members = ["fluxer_admin"]' \
'members = ["fluxer_admin", "fluxer_common"]' \
'resolver = "2"' \
'' \
'[workspace.package]' \
'edition = "2024"' \
'license = "AGPL-3.0-or-later"' \
'' \
'[profile.release]' \
'lto = "fat"' \
'codegen-units = 1' \
'strip = "symbols"' \
> Cargo.toml
ENV FLUXER_BUILD_VERSION="${BUILD_VERSION}"
@@ -54,6 +60,20 @@ RUN test "$(ls target/release/build/fluxer_admin-*/out/static/fonts/*.woff2 | wc
FROM debian:bookworm-slim AS runtime
ARG BUILD_VERSION=""
ARG SOURCE_SHA=""
ARG SOURCE_DATE=""
LABEL org.opencontainers.image.title="fluxer-admin"
LABEL org.opencontainers.image.description="Fluxer admin console"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/local/bin
File diff suppressed because it is too large Load Diff
-6
View File
@@ -41,9 +41,6 @@ pub const BAN_AVATAR_HASH_REMOVE: &str = "ban:avatar_hash:remove";
pub const BAN_PROFILE_SUBSTRING_ADD: &str = "ban:profile_substring:add";
pub const BAN_PROFILE_SUBSTRING_CHECK: &str = "ban:profile_substring:check";
pub const BAN_PROFILE_SUBSTRING_REMOVE: &str = "ban:profile_substring:remove";
pub const BILLING_MANAGE_SUBSCRIPTION: &str = "billing:manage_subscription";
pub const BILLING_REFUND: &str = "billing:refund";
pub const BILLING_VIEW: &str = "billing:view";
pub const BULK_ADD_GUILD_MEMBERS: &str = "bulk:add:guild_members";
pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
@@ -155,9 +152,6 @@ pub const ALL_ACLS: &[&str] = &[
BAN_PROFILE_SUBSTRING_ADD,
BAN_PROFILE_SUBSTRING_CHECK,
BAN_PROFILE_SUBSTRING_REMOVE,
BILLING_MANAGE_SUBSCRIPTION,
BILLING_REFUND,
BILLING_VIEW,
BULK_ADD_GUILD_MEMBERS,
BULK_DELETE_USERS,
BULK_UPDATE_GUILD_FEATURES,
-154
View File
@@ -1,154 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::types as generated_types;
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BillingOverview, InvoiceListResponse, PaymentListResponse, PaymentMethodListResponse,
RefundCancelResponse, SubscriptionResponse,
};
impl AdminApiClient {
pub async fn get_billing_overview(&self, user_id: &str) -> ApiResult<BillingOverview> {
let response = self
.generated()
.admin_billing_overview(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payments(&self, user_id: &str) -> ApiResult<PaymentListResponse> {
let response = self
.generated()
.admin_billing_list_payments(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_subscription(&self, user_id: &str) -> ApiResult<SubscriptionResponse> {
let response = self
.generated()
.admin_billing_get_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_payment_methods(
&self,
user_id: &str,
) -> ApiResult<PaymentMethodListResponse> {
let response = self
.generated()
.admin_billing_list_payment_methods(user_id)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn get_user_invoices(
&self,
user_id: &str,
limit: u32,
starting_after: Option<&str>,
) -> ApiResult<InvoiceListResponse> {
let limit_str = limit.to_string();
let mut params: Vec<(&str, &str)> = vec![("limit", &limit_str)];
if let Some(sa) = starting_after {
params.push(("starting_after", sa));
}
self.get(
&format!("/admin/billing/users/{user_id}/invoices"),
Some(&params),
)
.await
}
pub async fn issue_refund(
&self,
user_id: &str,
payment_intent_id: &str,
amount_cents: Option<u64>,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingRefundRequest {
amount_cents: amount_cents
.map(|value| crate::api::generated::nonzero_u64(value, "amount_cents"))
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
payment_intent_id: payment_intent_id.to_owned(),
reason: reason
.map(generated_types::AdminBillingRefundRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_refund(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn refund_policy_cancel_now(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<RefundCancelResponse> {
let body = generated_types::AdminBillingRefundLatestInvoiceCancelRequest {
reason: reason
.map(generated_types::AdminBillingRefundLatestInvoiceCancelRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
let response = self
.generated()
.admin_billing_refund_policy_cancel_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn cancel_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_cancel_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn cancel_subscription_immediately(
&self,
user_id: &str,
reason: Option<&str>,
) -> ApiResult<()> {
let body = generated_types::AdminBillingCancelImmediatelyRequest {
reason: reason
.map(generated_types::AdminBillingCancelImmediatelyRequestReason::try_from)
.transpose()
.map_err(|e| ApiError::Parse(e.to_string()))?,
};
self.generated()
.admin_billing_cancel_subscription_now(user_id, &body)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn reactivate_subscription(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_reactivate_subscription(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn end_premium_grace_period(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.admin_billing_end_premium_grace_period(user_id)
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
-4
View File
@@ -32,10 +32,6 @@ pub(crate) fn nonzero_u32(value: u32, field: &str) -> Result<std::num::NonZeroU3
std::num::NonZeroU32::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
pub(crate) fn nonzero_u64(value: u64, field: &str) -> Result<std::num::NonZeroU64, String> {
std::num::NonZeroU64::new(value).ok_or_else(|| format!("{field} must be greater than zero"))
}
#[cfg(test)]
mod tests {
use super::{number_to_u64, types::*};
+1
View File
@@ -136,6 +136,7 @@ impl AdminApiClient {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
delete_message_seconds: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
-1
View File
@@ -8,7 +8,6 @@ pub mod archives;
pub mod assets;
pub mod audit;
pub mod bans;
pub mod billing;
pub mod bulk;
pub mod client;
pub mod codes;
-39
View File
@@ -1,39 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BillingOverview {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SubscriptionResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PaymentMethodListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InvoiceListResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RefundCancelResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
-2
View File
@@ -4,7 +4,6 @@ mod admin_api_keys;
mod applications;
mod archives;
mod audit;
mod billing;
mod bulk;
mod codes;
mod common;
@@ -24,7 +23,6 @@ pub use admin_api_keys::*;
pub use applications::*;
pub use archives::*;
pub use audit::*;
pub use billing::*;
pub use bulk::*;
pub use codes::*;
pub use common::*;
+118 -20
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::normalize_public_endpoint_from_env;
use std::env;
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
@@ -42,14 +43,14 @@ pub enum RuntimeEnv {
impl AdminConfig {
pub fn from_env() -> Self {
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
let admin_endpoint = trim_trailing_slash(&read_env(
let admin_endpoint = normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_ADMIN_ENDPOINT",
"https://admin.fluxer.app",
));
let oauth_redirect_uri = read_env_preferred(
)));
let oauth_redirect_uri = normalize_public_endpoint_from_env(&read_env_preferred(
&["FLUXER_ADMIN_OAUTH_REDIRECT_URI"],
&format!("{admin_endpoint}/oauth2_callback"),
);
));
Self {
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
@@ -63,17 +64,19 @@ impl AdminConfig {
"FLUXER_API_ENDPOINT",
"https://api.fluxer.app",
)),
media_endpoint: trim_trailing_slash(&read_env(
media_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_MEDIA_ENDPOINT",
"https://media.fluxer.app",
))),
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
static_cdn_endpoint: trim_trailing_slash(&read_env("FLUXER_STATIC_CDN_ENDPOINT", "")),
admin_endpoint,
web_app_endpoint: trim_trailing_slash(&read_env(
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
"FLUXER_APP_ENDPOINT",
"https://app.fluxer.app",
)),
))),
kv_url: read_env("FLUXER_KV_URL", ""),
oauth_client_id: read_env(
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
@@ -166,6 +169,39 @@ pub(crate) fn read_bool_env(names: &[&str], fallback: bool) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 11] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
"FLUXER_APP_ENDPOINT",
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
let _guard = ENV_LOCK.lock().unwrap();
for name in MANAGED_ENV {
unsafe { env::remove_var(name) };
}
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
for (name, value) in vars {
unsafe { env::set_var(name, value) };
}
let config = AdminConfig::from_env();
for (name, _) in vars {
unsafe { env::remove_var(name) };
}
config
}
#[test]
fn normalize_base_path_strips_trailing_slashes() {
@@ -287,18 +323,7 @@ mod tests {
#[test]
fn from_env_uses_defaults() {
for var in &[
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
"FLUXER_ADMIN_ENDPOINT",
"FLUXER_ADMIN_OAUTH_CLIENT_ID",
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"FLUXER_MASTER_CONFIG",
] {
unsafe { env::remove_var(var) };
}
let config = AdminConfig::from_env();
let config = config_from_env(&[]);
assert_eq!(config.env, RuntimeEnv::Development);
assert_eq!(config.host, "0.0.0.0");
assert_eq!(config.port, 3020);
@@ -308,4 +333,77 @@ mod tests {
"https://admin.fluxer.app/oauth2_callback"
);
}
#[test]
fn a_non_default_public_port_reaches_the_public_endpoints() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "http://fluxer.example:19080/admin");
assert_eq!(config.media_endpoint, "http://fluxer.example:19080/media");
assert_eq!(config.web_app_endpoint, "http://fluxer.example:19080");
assert_eq!(config.static_cdn_endpoint, "https://cdn.example.net");
assert_eq!(
config.oauth_redirect_uri,
format!("{}/oauth2_callback", config.admin_endpoint)
);
}
#[test]
fn a_default_public_port_leaves_the_public_endpoints_alone() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "443"),
("FLUXER_ADMIN_ENDPOINT", "https://fluxer.example/admin"),
("FLUXER_APP_ENDPOINT", "https://fluxer.example"),
("FLUXER_MEDIA_ENDPOINT", "https://fluxer.example/media"),
("FLUXER_STATIC_CDN_ENDPOINT", "https://fluxer.example"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"https://fluxer.example/admin/oauth2_callback",
),
]);
assert_eq!(config.admin_endpoint, "https://fluxer.example/admin");
assert_eq!(config.media_endpoint, "https://fluxer.example/media");
assert_eq!(config.web_app_endpoint, "https://fluxer.example");
assert_eq!(config.static_cdn_endpoint, "https://fluxer.example");
assert_eq!(
config.oauth_redirect_uri,
"https://fluxer.example/admin/oauth2_callback"
);
}
#[test]
fn the_oauth_redirect_uri_matches_the_api_derived_admin_endpoint() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
(
"FLUXER_ADMIN_OAUTH_REDIRECT_URI",
"http://fluxer.example/admin/oauth2_callback",
),
]);
let api_admin_endpoint = fluxer_common::config::normalize_public_endpoint(
"http://fluxer.example/admin",
"fluxer.example",
Some(19080),
);
assert_eq!(
config.oauth_redirect_uri,
format!("{api_admin_endpoint}/oauth2_callback")
);
}
}
+72 -62
View File
@@ -2,24 +2,43 @@
use axum::{
body::{Body, to_bytes},
extract::Request,
extract::{Request, State},
http::{HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::RngExt;
use crate::middleware::auth::AuthContext;
use crate::session::{create_csrf_token, verify_csrf_token};
use crate::state::AppState;
const CSRF_COOKIE_NAME: &str = "csrf_token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
const CSRF_HEADER_NAME: &str = "x-csrf-token";
const TOKEN_LENGTH: usize = 32;
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
let existing_token = extract_csrf_cookie(&request);
let token = existing_token.unwrap_or_else(generate_csrf_token);
pub async fn csrf_protection(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let is_production = config.is_production();
let user_id = request
.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.session.user_id.clone())
.unwrap_or_default();
let token = extract_csrf_cookie(&request)
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
request.extensions_mut().insert(CsrfToken(token.clone()));
if matches!(
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
let query_token = extract_csrf_from_query(&request);
let mut submitted = query_token.or(header_token);
@@ -43,40 +65,58 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
request = restored_request;
submitted = body_token;
}
match submitted {
Some(ref submitted_token) if submitted_token == &token => {}
_ => {
return StatusCode::FORBIDDEN.into_response();
}
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
});
if !accepted {
return StatusCode::FORBIDDEN.into_response();
}
}
}
let mut response = next.run(request).await;
let cookie_value = format!(
"{}={}; Path=/; SameSite=Lax; HttpOnly",
CSRF_COOKIE_NAME, token
);
let cookie_name = if is_production {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if is_production { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
if is_production
&& let Ok(value) = HeaderValue::from_str(&format!(
"{CSRF_COOKIE_NAME}=; Path=/; SameSite=Lax; HttpOnly; Max-Age=0"
))
{
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
let mut legacy = None;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("csrf_token=") {
if let Some(value) = pair.strip_prefix("__Host-csrf_token=") {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
}
} else if let Some(value) = pair.strip_prefix("csrf_token=")
&& legacy.is_none()
{
let trimmed = value.trim();
if !trimmed.is_empty() {
legacy = Some(trimmed.to_owned());
}
}
}
None
legacy
}
fn extract_csrf_header(request: &Request) -> Option<String> {
@@ -127,18 +167,22 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn generate_csrf_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; TOKEN_LENGTH] = rng.random();
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&format!("{byte:02x}"));
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
return matches!(site, "same-origin" | "same-site" | "none");
}
match request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
None => true,
}
s
}
#[derive(Clone, Debug)]
@@ -156,40 +200,6 @@ pub fn get_csrf_token(request: &Request) -> String {
mod tests {
use super::*;
#[test]
fn generate_csrf_token_correct_length() {
let token = generate_csrf_token();
assert_eq!(
token.len(),
TOKEN_LENGTH * 2,
"token must be {} hex chars",
TOKEN_LENGTH * 2
);
}
#[test]
fn generate_csrf_token_is_valid_hex() {
let token = generate_csrf_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must contain only hex chars: {token}"
);
}
#[test]
fn generate_csrf_token_is_unique() {
let a = generate_csrf_token();
let b = generate_csrf_token();
assert_ne!(a, b, "consecutive tokens must differ");
}
#[test]
fn hex_encode_produces_correct_output() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
}
#[test]
fn oauth2_callback_is_exempt() {
let exempt = IGNORED_PATH_SUFFIXES
+1 -1
View File
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
)
}
fn json_string(value: &str) -> String {
pub(crate) fn json_string(value: &str) -> String {
serde_json::to_string(value)
.expect("JSON string serialization cannot fail")
.replace('<', "\\u003c")
-16
View File
@@ -150,22 +150,6 @@ pub async fn render(
},
))
}
"billing" => {
if config.self_hosted || !acl::has_permission(admin_acls, acl::BILLING_VIEW) {
return None;
}
let billing = client
.get_billing_overview(guild_id)
.await
.log_error("load guild billing overview")
.map(|b| b.data);
Some(tabs::billing::billing_tab(
config,
guild_id,
billing.as_ref(),
csrf_token,
))
}
"applications" => {
if !acl::has_any_permission(
admin_acls,
+4 -1
View File
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn(middleware::csrf::csrf_protection))
.layer(from_fn_with_state(
state.clone(),
middleware::csrf::csrf_protection,
))
.layer(from_fn(middleware::self_hosted::self_hosted_override))
.layer(from_fn_with_state(
state.clone(),
-49
View File
@@ -399,55 +399,6 @@ pub async fn dispatch(
"Bulk message deletion cancelled successfully",
"Failed to cancel bulk message deletion",
),
"refund_payment" => {
let Some(pi) = form.clean("payment_intent_id") else {
return DispatchOutcome::error("Payment intent ID is required");
};
let amt = form.parse_u64("amount_cents");
let reason = get("reason");
DispatchOutcome::from_result(
client
.issue_refund(user_id, &pi, amt, reason.as_deref())
.await,
"Refund issued successfully",
"Failed to issue refund",
)
}
"refund_policy_cancel_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.refund_policy_cancel_now(user_id, reason.as_deref())
.await,
"Refund policy cancellation completed successfully",
"Failed to apply refund policy cancellation",
)
}
"cancel_subscription" => DispatchOutcome::from_result(
client.cancel_subscription(user_id).await,
"Subscription cancelled successfully",
"Failed to cancel subscription",
),
"cancel_subscription_now" => {
let reason = get("reason");
DispatchOutcome::from_result(
client
.cancel_subscription_immediately(user_id, reason.as_deref())
.await,
"Subscription cancelled immediately",
"Failed to cancel subscription immediately",
)
}
"reactivate_subscription" => DispatchOutcome::from_result(
client.reactivate_subscription(user_id).await,
"Subscription reactivated successfully",
"Failed to reactivate subscription",
),
"end_premium_grace_period" => DispatchOutcome::from_result(
client.end_premium_grace_period(user_id).await,
"Premium grace period ended successfully",
"Failed to end premium grace period",
),
"message_shred" => {
let csv = form.first("csv_data").unwrap_or_default();
match parse_message_shred_csv(csv) {
-38
View File
@@ -155,44 +155,6 @@ pub async fn render(
csrf_token,
))
}
"billing" => {
if config.self_hosted
|| !acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
{
return None;
}
let can_view_billing = acl::has_permission(admin_acls, acl::BILLING_VIEW);
let b = if can_view_billing {
client
.get_billing_overview(user_id)
.await
.log_error("load user billing overview")
} else {
None
};
let invoices = if can_view_billing {
client
.get_user_invoices(user_id, 25, None)
.await
.log_error("load user invoices")
} else {
None
};
Some(tabs::billing::billing_tab(
config,
user_id,
b.as_ref().map(|v| &v.data),
invoices.as_ref().map(|v| &v.data),
csrf_token,
))
}
"guilds" => {
let g = client
.get_user_guilds(user_id, Some(200), None, None, Some(true))
+31
View File
@@ -2,6 +2,7 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
if diff == 0 { Some(data) } else { None }
}
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
let mut rng = rand::rng();
let nonce: [u8; 16] = rng.random();
let payload = URL_SAFE_NO_PAD.encode(format!(
"{}:{}",
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
URL_SAFE_NO_PAD.encode(nonce)
));
sign_data(&payload, secret_key)
}
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
let Some(data) = verify_signature(token, secret_key) else {
return false;
};
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
return false;
};
let Ok(payload) = String::from_utf8(decoded) else {
return false;
};
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
return false;
};
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
Err(_) => false,
}
}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_COOKIE_NAME: &str = "admin_session";
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
@@ -309,7 +310,7 @@ pub fn message_list(
}
pub fn message_deletion_script(csrf_token: &str) -> Markup {
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
let csrf = json_string(csrf_token);
let script = r#"(function() {
var csrf = __CSRF__;
function bp() {
@@ -1,94 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
pub fn billing_tab(
config: &AdminConfig,
guild_id: &str,
billing: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this guild."
}
}))
}
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=refresh_billing"}
class="block" {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Refresh Billing Data"))
}))
}
form method="post"
action={(base) "/guilds/" (guild_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
input type="text" name="reason" placeholder="Reason (optional)"
class="block w-full rounded-md border border-neutral-300 px-3 \
py-2 text-sm shadow-sm focus:border-brand-primary \
focus:outline-none focus:ring-1 focus:ring-brand-primary";
(form_actions(html! {
(danger_button("Cancel Subscription"))
}))
}
}
}
}))
}
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("none");
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription Status", html! {
span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs \
font-medium bg-neutral-100 text-neutral-700" {
(sub_status)
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
@@ -3,7 +3,6 @@
pub mod applications;
pub mod archives;
pub mod audit_log;
pub mod billing;
pub mod emojis;
pub mod features;
pub mod members;
@@ -22,7 +22,6 @@ use maud::{Markup, html};
pub const USER_TABS: &[(&str, &str)] = &[
("overview", "Overview"),
("account", "Account"),
("billing", "Billing"),
("guilds", "Guilds"),
("dm_history", "DM History"),
("group_dms", "Group DMs"),
@@ -164,21 +163,10 @@ fn render_user_detail(
}
}
fn user_tab_visible(config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
fn user_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String]) -> bool {
match tab_id {
"overview" | "account" | "guilds" | "dm_history" | "group_dms" | "reports"
| "moderation" => true,
"billing" => {
!config.self_hosted
&& acl::has_any_permission(
admin_acls,
&[
acl::BILLING_VIEW,
acl::BILLING_REFUND,
acl::BILLING_MANAGE_SUBSCRIPTION,
],
)
}
"relationships" => acl::has_permission(admin_acls, acl::USER_LIST_RELATIONSHIPS),
"applications" => acl::has_permission(admin_acls, acl::APPLICATION_LIST_BY_OWNER),
"archives" => acl::has_any_permission(
@@ -1,410 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig,
templates::components::{
badge::{BadgeVariant, badge},
form::{csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, detail_row},
},
};
use maud::{Markup, html};
const INPUT_CLS: &str = "block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm \
shadow-sm focus:border-brand-primary focus:outline-none focus:ring-1 \
focus:ring-brand-primary";
pub fn billing_tab(
config: &AdminConfig,
user_id: &str,
billing: Option<&serde_json::Value>,
invoices: Option<&serde_json::Value>,
csrf_token: &str,
) -> Markup {
let base = &config.base_path;
html! {
div class="space-y-6" {
@if let Some(data) = billing {
(render_billing_summary(data))
(render_subscription(data))
(render_payment_methods(data))
(render_payments(data))
} @else {
(card_with_header("Billing", html! {
p class="text-sm text-neutral-500" {
"No billing information available for this user."
}
}))
}
@if let Some(data) = invoices {
(render_invoices(data))
}
(render_actions(base, user_id, csrf_token))
}
}
}
fn subscription_badge_variant(status: &str) -> BadgeVariant {
match status {
"active" | "trialing" => BadgeVariant::Success,
"past_due" | "unpaid" | "incomplete" => BadgeVariant::Warning,
"canceled" | "incomplete_expired" => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_billing_summary(data: &serde_json::Value) -> Markup {
let customer_id = data
.get("stripe_customer_id")
.and_then(|v| v.as_str())
.unwrap_or("\u{2014}");
let sub_status = data
.get("subscription")
.and_then(|s| s.get("status"))
.and_then(|v| v.as_str());
let period_end = data
.get("subscription")
.and_then(|s| s.get("current_period_end"))
.and_then(|v| v.as_str());
html! {
(card_with_header("Summary", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Stripe Customer", html! {
span class="text-xs" { (customer_id) }
}))
(detail_row("Subscription", html! {
@if let Some(status) = sub_status {
(badge(status, subscription_badge_variant(status)))
} @else {
span class="text-sm text-neutral-900" { "none" }
}
}))
@if let Some(end) = period_end {
(detail_row("Current Period Ends", html! { (end) }))
}
}
}))
}
}
fn render_subscription(data: &serde_json::Value) -> Markup {
let sub = match data.get("subscription") {
Some(s) if !s.is_null() => s,
_ => return html! {},
};
let status = sub
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let sub_id = sub.get("id").and_then(|v| v.as_str());
let plan_interval = sub.get("plan_interval").and_then(|v| v.as_str());
let period_start = sub.get("current_period_start").and_then(|v| v.as_str());
let period_end = sub.get("current_period_end").and_then(|v| v.as_str());
let cancel_at_period_end = sub
.get("cancel_at_period_end")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Subscription", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Status", html! {
(badge(status, subscription_badge_variant(status)))
}))
@if let Some(id) = sub_id {
(detail_row("ID", html! {
span class="text-xs" { (id) }
}))
}
@if let Some(interval) = plan_interval {
(detail_row("Plan Interval", html! { (interval) }))
}
@if let Some(start) = period_start {
(detail_row("Period Start", html! { (start) }))
}
@if let Some(end) = period_end {
(detail_row("Period End", html! { (end) }))
}
(detail_row("Cancel at Period End", html! {
@if cancel_at_period_end { "yes" } @else { "no" }
}))
}
}))
}
}
fn render_payment_methods(data: &serde_json::Value) -> Markup {
let methods = data.get("payment_methods").and_then(|v| v.as_array());
let empty = methods.is_none() || methods.is_some_and(|m| m.is_empty());
html! {
(card_with_header("Payment Methods", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payment methods on file." }
} @else if let Some(pms) = methods {
div class="space-y-3" {
@for pm in pms {
@let pm_type = pm.get("type").and_then(|v| v.as_str()).unwrap_or("unknown");
@let brand = pm.get("card_brand").and_then(|v| v.as_str());
@let last4 = pm.get("card_last4").and_then(|v| v.as_str());
@let pm_id = pm.get("id").and_then(|v| v.as_str()).unwrap_or("");
@let display = match (brand, last4) {
(Some(b), Some(l)) => format!("{b} **** {l}"),
_ => pm_type.to_string(),
};
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3" {
p class="text-sm text-neutral-900" { (display) }
p class="text-xs text-neutral-500" { (pm_id) }
}
}
}
}
}))
}
}
fn render_payments(data: &serde_json::Value) -> Markup {
let payments = data.get("payments").and_then(|v| v.as_array());
let empty = payments.is_none() || payments.is_some_and(|p| p.is_empty());
html! {
(card_with_header("Payments", html! {
@if empty {
p class="text-sm text-neutral-500" { "No payments recorded." }
} @else if let Some(ps) = payments {
div class="space-y-3" {
@for p in ps { (payment_row(p)) }
}
}
}))
}
}
fn payment_row(p: &serde_json::Value) -> Markup {
let amount = p.get("amount_cents").and_then(|v| v.as_i64()).unwrap_or(0);
let currency = p.get("currency").and_then(|v| v.as_str()).unwrap_or("");
let status = p
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let created = p.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
let display_amount = format!("{:.2} {}", amount as f64 / 100.0, currency.to_uppercase());
let variant = match status {
"completed" | "succeeded" => BadgeVariant::Success,
"pending" | "processing" => BadgeVariant::Info,
"failed" | "canceled" => BadgeVariant::Danger,
"refunded" | "partially_refunded" => BadgeVariant::Warning,
_ => BadgeVariant::Default,
};
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="flex items-center justify-between" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status, variant))
}
span class="text-xs text-neutral-500" { (created) }
}
}
}
}
fn invoice_badge_variant(status: Option<&str>) -> BadgeVariant {
match status {
Some("paid") => BadgeVariant::Success,
Some("open" | "draft") => BadgeVariant::Info,
Some("uncollectible" | "void") => BadgeVariant::Danger,
_ => BadgeVariant::Default,
}
}
fn render_invoices(data: &serde_json::Value) -> Markup {
let invoices = data.get("invoices").and_then(|v| v.as_array());
let empty = invoices.is_none() || invoices.is_some_and(|i| i.is_empty());
let has_more = data
.get("has_more")
.and_then(|v| v.as_bool())
.unwrap_or(false);
html! {
(card_with_header("Invoices", html! {
@if empty {
p class="text-sm text-neutral-500" { "No invoices on file." }
} @else if let Some(items) = invoices {
div class="space-y-3" {
@for invoice in items {
(invoice_row(invoice))
}
@if has_more {
p class="text-xs text-neutral-500" {
"More invoices exist beyond this list."
}
}
}
}
}))
}
}
fn invoice_row(invoice: &serde_json::Value) -> Markup {
let amount = invoice
.get("amount_paid")
.and_then(|v| v.as_i64())
.unwrap_or(0);
let currency = invoice
.get("currency")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = invoice.get("status").and_then(|v| v.as_str());
let created = invoice
.get("created")
.and_then(|v| v.as_i64())
.map(format_unix_timestamp)
.unwrap_or_default();
let display_amount = format_amount(amount, currency);
let status_label = status.unwrap_or("unknown");
let billing_reason = invoice.get("billing_reason").and_then(|v| v.as_str());
let invoice_id = invoice.get("id").and_then(|v| v.as_str()).unwrap_or("");
let subscription_id = invoice.get("subscription_id").and_then(|v| v.as_str());
let payment_intent_id = invoice.get("payment_intent_id").and_then(|v| v.as_str());
let charge_id = invoice.get("charge_id").and_then(|v| v.as_str());
let hosted_invoice_url = invoice.get("hosted_invoice_url").and_then(|v| v.as_str());
let invoice_pdf = invoice.get("invoice_pdf").and_then(|v| v.as_str());
html! {
div class="rounded-lg border border-neutral-200 bg-neutral-50 p-4" {
div class="space-y-3" {
div class="flex items-center justify-between gap-3" {
div class="flex items-center gap-2" {
span class="text-sm font-medium text-neutral-900" {
(display_amount)
}
(badge(status_label, invoice_badge_variant(status)))
}
span class="text-xs text-neutral-500" { (created) }
}
@if let Some(reason) = billing_reason {
p class="text-sm text-neutral-500" { (reason) }
}
dl class="space-y-1" {
(compact_detail_row("id", invoice_id))
@if let Some(id) = subscription_id {
(compact_detail_row("subscription", id))
}
@if let Some(id) = payment_intent_id {
(compact_detail_row("payment_intent", id))
}
@if let Some(id) = charge_id {
(compact_detail_row("charge", id))
}
}
@if hosted_invoice_url.is_some() || invoice_pdf.is_some() {
div class="flex items-center gap-3 text-sm" {
@if let Some(url) = hosted_invoice_url {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"View"
}
}
@if let Some(url) = invoice_pdf {
a href=(url) target="_blank" rel="noreferrer noopener"
class="text-blue-600 hover:text-blue-800 hover:underline" {
"PDF"
}
}
}
}
}
}
}
}
fn compact_detail_row(label: &str, value: &str) -> Markup {
html! {
div class="grid grid-cols-1 gap-1 text-xs sm:grid-cols-3" {
dt class="text-neutral-500" { (label) }
dd class="break-all text-neutral-700 sm:col-span-2" { (value) }
}
}
}
fn format_amount(amount_minor: i64, currency: &str) -> String {
let code = currency.trim().to_uppercase();
if code.is_empty() {
format!("{:.2}", amount_minor as f64 / 100.0)
} else {
format!("{:.2} {code}", amount_minor as f64 / 100.0)
}
}
fn format_unix_timestamp(value: i64) -> String {
time::OffsetDateTime::from_unix_timestamp(value)
.ok()
.and_then(|ts| {
ts.format(&time::format_description::well_known::Rfc3339)
.ok()
})
.unwrap_or_else(|| value.to_string())
}
fn render_actions(base: &str, user_id: &str, csrf_token: &str) -> Markup {
html! {
(card_with_header("Billing Actions", html! {
div class="space-y-4" {
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription_now"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel subscription immediately, no refund."
}
input type="text" name="reason" placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Cancel Now"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=cancel_subscription"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm text-neutral-700" {
"Cancel at renewal (access until period end)."
}
(form_actions(html! {
(submit_button("Cancel at Renewal"))
}))
}
}
form method="post"
action={(base) "/users/" (user_id) "?tab=billing&action=refund_payment"} {
(csrf_input(csrf_token))
div class="space-y-3" {
p class="text-sm font-medium text-neutral-700" {
"Manual Refund"
}
div class="grid grid-cols-1 gap-3 sm:grid-cols-2" {
input type="text" name="payment_intent_id"
placeholder="pi_..." required
class=(INPUT_CLS);
input type="number" name="amount_cents" min="1"
placeholder="Amount cents (blank = full)"
class=(INPUT_CLS);
}
input type="text" name="reason"
placeholder="Reason (optional)"
class=(INPUT_CLS);
(form_actions(html! {
(danger_button("Refund"))
}))
}
}
}
}))
}
}
@@ -5,7 +5,6 @@ use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
pub mod account;
pub mod applications;
pub mod archives;
pub mod billing;
pub mod dm_history;
pub mod group_dm;
pub mod guilds;
+267
View File
@@ -0,0 +1,267 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
Json, Router,
body::{Body, to_bytes},
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
const ADMIN_ORIGIN: &str = "https://admin.example.test";
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
struct TestApp {
router: Router,
session_cookie: String,
}
#[tokio::test]
async fn clean_browser_can_submit_an_action() {
let app = setup().await;
let cookie = app.session_cookie.clone();
let (cookie_token, page_token) = load_page(&app, &cookie).await;
assert_eq!(cookie_token, page_token);
let with_csrf = format!("{cookie}; __Host-csrf_token={cookie_token}");
let status = submit_action(&app, &with_csrf, &page_token).await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn legacy_csrf_cookie_does_not_wedge_actions() {
let app = setup().await;
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
let (cookie_token, page_token) = load_page(&app, &stale).await;
assert_eq!(cookie_token, page_token);
let both = format!("{stale}; __Host-csrf_token={cookie_token}");
let status = submit_action(&app, &both, &page_token).await;
assert_eq!(
status,
StatusCode::OK,
"a leftover unsigned csrf_token cookie must not block actions"
);
}
#[tokio::test]
async fn production_responses_expire_the_legacy_csrf_cookie() {
let app = setup().await;
let stale = format!("{}; csrf_token={LEGACY_HEX_TOKEN}", app.session_cookie);
let headers = page_headers(&app, &stale).await;
let expiry = headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find(|value| value.starts_with("csrf_token=;"))
.unwrap_or_else(|| panic!("legacy cookie was not expired: {headers:?}"));
assert!(expiry.contains("Max-Age=0"), "{expiry}");
assert!(expiry.contains("Path=/"), "{expiry}");
}
async fn setup() -> TestApp {
let api_endpoint = spawn_mock_api().await;
let router = build_router(production_config(api_endpoint));
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
}
}
fn production_config(api_endpoint: String) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Production,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: ADMIN_ORIGIN.to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
kv_url: String::new(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
release_channel: "test".to_owned(),
self_hosted: false,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
async fn page_headers(app: &TestApp, cookie: &str) -> HeaderMap {
app.router
.clone()
.oneshot(page_request(cookie))
.await
.unwrap()
.headers()
.clone()
}
async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
let response = app
.router
.clone()
.oneshot(page_request(cookie))
.await
.unwrap();
let status = response.status();
let headers = response.headers().clone();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(status, StatusCode::OK, "{text}");
let cookie_token = host_csrf_cookie(&headers)
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
(cookie_token, page_token)
}
fn page_request(cookie: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri("/admin-api-keys")
.header(header::COOKIE, cookie)
.header("sec-fetch-site", "same-origin")
.body(Body::empty())
.unwrap()
}
async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusCode {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/admin-api-keys?action=create")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(header::COOKIE, cookie)
.header(header::ORIGIN, ADMIN_ORIGIN)
.header("sec-fetch-site", "same-origin")
.header("HX-Request", "true")
.header("HX-Boosted", "true")
.header("HX-Target", "body")
.body(Body::from(format!(
"_csrf={form_token}&name=Legacy+Cookie+Key&acls=*"
)))
.unwrap(),
)
.await
.unwrap();
response.status()
}
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
value
.split(';')
.next()
.and_then(|pair| pair.trim().strip_prefix("__Host-csrf_token="))
.map(str::to_owned)
})
}
fn form_csrf_value(body: &str) -> Option<String> {
let marker = r#"name="_csrf" value=""#;
body.match_indices(marker)
.filter_map(|(index, _)| {
let rest = &body[index + marker.len()..];
let end = rest.find('"')?;
Some(rest[..end].to_owned())
})
.find(|value| !value.is_empty())
}
async fn spawn_mock_api() -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(mock_api))
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(method: Method, uri: Uri) -> Response {
match (method, uri.path()) {
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
(Method::POST, "/admin/api-keys") => Json(json!({
"key_id": "1900000000000000001",
"key": "fa_1900000000000000001_OneTimeSecretForTests",
"name": "Legacy Cookie Key",
"created_at": "2026-07-10T15:00:00.000Z",
"expires_at": null,
"acls": ["*"]
}))
.into_response(),
_ => (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(),
}
}
fn admin_user() -> Value {
json!({
"id": "1500000000000000000",
"username": "AdminUser",
"discriminator": 1,
"avatar": null,
"banner": null,
"email": "[email protected]",
"email_verified": true,
"email_bounced": false,
"global_name": "AdminUser",
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-US",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"suspicious_activity_flags": 0,
"phone_verification_deferred": false,
"has_totp": false,
"authenticator_types": [],
"has_verified_phone": false,
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
+19 -3
View File
@@ -23,11 +23,26 @@ COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm --filter @fluxer/config run --if-present generate
RUN pnpm deploy --legacy --filter=fluxer_api --prod /out
RUN pnpm --filter fluxer_api run build
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
FROM node:24-bookworm-slim
ARG BUILD_VERSION
ARG SOURCE_SHA
ARG SOURCE_DATE
LABEL org.opencontainers.image.title="fluxer-api"
LABEL org.opencontainers.image.description="Fluxer HTTP API and background workers"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
LABEL org.opencontainers.image.vendor="Fluxer"
LABEL org.opencontainers.image.url="https://fluxer.app"
LABEL org.opencontainers.image.documentation="https://docs.fluxer.app"
LABEL org.opencontainers.image.source="https://github.com/fluxerapp/fluxer"
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
LABEL org.opencontainers.image.revision="${SOURCE_SHA}"
LABEL org.opencontainers.image.created="${SOURCE_DATE}"
LABEL app.fluxer.build-version="${BUILD_VERSION}"
WORKDIR /usr/src/app/fluxer_api
@@ -48,6 +63,7 @@ RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/s
RUN corepack enable && corepack prepare [email protected] --activate
COPY --from=deploy /out ./
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs
RUN rm -rf pkgs && \
@@ -57,7 +73,7 @@ RUN rm -rf pkgs && \
ENV HOME=/usr/src/app
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
ENV NODE_ENV=production
ENV NODE_OPTIONS="--max-old-space-size=2048"
ENV NODE_OPTIONS="--enable-source-maps"
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
ENV BUILD_VERSION=${BUILD_VERSION}
@@ -65,4 +81,4 @@ USER 65532:65532
EXPOSE 8080
CMD ["./node_modules/.bin/tsx", "src/AppEntrypoint.ts"]
CMD ["node", "dist/AppEntrypoint.js"]
+15 -2
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"test": "vitest run",
"typecheck": "tsgo --noEmit",
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
@@ -17,6 +18,7 @@
"@bluesky-social/jwk-jose": "catalog:",
"@bluesky-social/oauth-client-node": "catalog:",
"@bufbuild/protobuf": "^2.12.0",
"@elastic/elasticsearch": "catalog:",
"@fluxer/config": "workspace:*",
"@fluxer/constants": "workspace:*",
"@fluxer/date_utils": "workspace:*",
@@ -29,6 +31,9 @@
"@fluxer/logger": "workspace:*",
"@fluxer/schema": "workspace:*",
"@fluxer/snowflake": "workspace:*",
"@hono/node-server": "catalog:",
"@messageformat/core": "catalog:",
"@messageformat/parser": "catalog:",
"@pkgs/cache": "workspace:*",
"@pkgs/captcha": "workspace:*",
"@pkgs/cassandra": "workspace:*",
@@ -49,35 +54,43 @@
"@pkgs/worker": "workspace:*",
"@simplewebauthn/server": "catalog:",
"@types/node": "catalog:",
"@vvo/tzdb": "catalog:",
"archiver": "catalog:",
"argon2": "catalog:",
"bowser": "catalog:",
"cassandra-driver": "catalog:",
"emoji-regex": "catalog:",
"fast-xml-parser": "catalog:",
"hi-base32": "catalog:",
"hono": "catalog:",
"html-entities": "catalog:",
"idna-uts46-hx": "catalog:",
"ioredis": "catalog:",
"itty-time": "catalog:",
"jose": "catalog:",
"livekit-server-sdk": "catalog:",
"lodash": "catalog:",
"luxon": "catalog:",
"maxmind": "catalog:",
"mime": "catalog:",
"nats": "catalog:",
"nodemailer": "catalog:",
"pg": "catalog:",
"pino": "catalog:",
"sharp": "catalog:",
"stripe": "catalog:",
"tempy": "catalog:",
"transliteration": "catalog:",
"tsx": "catalog:",
"uint8array-extras": "catalog:",
"undici": "catalog:",
"validator": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/archiver": "catalog:",
"@types/lodash": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"esbuild": "catalog:",
"msw": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
+5 -1
View File
@@ -7,6 +7,8 @@
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
@@ -14,6 +16,8 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+9 -3
View File
@@ -1,20 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CacheLogger} from '@pkgs/cache/src/CacheProviderTypes';
import type {CacheLookupResult} from '@pkgs/cache/src/ICacheService';
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
export function parseCachedValue<T>(value: string, logger?: CacheLogger): CacheLookupResult<T> {
try {
return JSON.parse(value);
return {hit: true, value: JSON.parse(value)};
} catch (error) {
if (logger) {
const truncatedValue = value.length > 200 ? `${value.substring(0, 200)}...` : value;
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({errorMessage, value: truncatedValue}, '[CacheProvider] JSON parse error');
}
return null;
return {hit: false};
}
}
export function safeJsonParse<T>(value: string, logger?: CacheLogger): T | null {
const parsed = parseCachedValue<T>(value, logger);
return parsed.hit ? parsed.value : null;
}
export function serializeValue<T>(value: T): string {
return JSON.stringify(value);
}
+160 -9
View File
@@ -1,17 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const CACHE_INFLIGHT_MAX_ENTRIES = 10000;
const CACHE_INFLIGHT_JOIN_RETRIES = 1;
const CACHE_PRODUCE_TIMEOUT_MS = 15000;
const CACHE_PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
interface CacheMSetEntry<T> {
key: string;
value: T;
ttlSeconds?: number;
}
interface CacheProduceTracking {
generation: number;
produces: number;
}
interface CacheProduceAbandonment {
abandoned: boolean;
}
export type CacheLookupResult<T> = {hit: true; value: T} | {hit: false};
type CacheTtlSeconds<T> = number | ((value: T) => number);
type CacheJoinResult<T> = {joined: true; value: T} | {joined: false; error: unknown};
export abstract class ICacheService {
abstract get<T>(key: string): Promise<T | null>;
private readonly inflightValues = new Map<string, Promise<unknown>>();
private readonly produceInvalidations = new Map<string, CacheProduceTracking>();
abstract getEntry<T>(key: string): Promise<CacheLookupResult<T>>;
abstract set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
abstract delete(key: string): Promise<void>;
protected abstract deleteEntry(key: string): Promise<void>;
async delete(key: string): Promise<void> {
const tracked = this.produceInvalidations.get(key);
if (tracked) {
tracked.generation += 1;
}
await this.deleteEntry(key);
}
abstract getAndDelete<T>(key: string): Promise<T | null>;
@@ -45,13 +76,133 @@ export abstract class ICacheService {
abstract sismember(key: string, member: string): Promise<boolean>;
async getOrSet<T>(key: string, valueFactory: () => Promise<T>, ttlSeconds?: number): Promise<T> {
const existingValue = await this.get<T>(key);
if (existingValue !== null) {
return existingValue;
async get<T>(key: string): Promise<T | null> {
const entry = await this.getEntry<T>(key);
return entry.hit ? entry.value : null;
}
async getOrSet<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds?: CacheTtlSeconds<T>,
produceTimeoutMs: number = CACHE_PRODUCE_TIMEOUT_MS,
): Promise<T> {
let generation = this.trackProduce(key);
try {
for (let attempt = 0; ; attempt++) {
const existing = await this.getEntry<T>(key);
if (existing.hit) {
return existing.value;
}
const inflight = this.inflightValues.get(key);
if (!inflight) {
return await this.produceSingleFlight(key, valueFactory, ttlSeconds, generation, produceTimeoutMs);
}
const joined = await this.joinInflight<T>(inflight);
if (joined.joined) {
return joined.value;
}
if (attempt >= CACHE_INFLIGHT_JOIN_RETRIES) {
throw joined.error;
}
generation = this.currentGeneration(key);
}
} finally {
this.releaseProduce(key);
}
const newValue = await valueFactory();
await this.set(key, newValue, ttlSeconds);
return newValue;
}
private trackProduce(key: string): number {
const tracked = this.produceInvalidations.get(key);
if (tracked) {
tracked.produces += 1;
return tracked.generation;
}
this.produceInvalidations.set(key, {generation: 0, produces: 1});
return 0;
}
private currentGeneration(key: string): number {
return this.produceInvalidations.get(key)?.generation ?? 0;
}
private releaseProduce(key: string): void {
const tracked = this.produceInvalidations.get(key);
if (!tracked) {
return;
}
tracked.produces -= 1;
if (tracked.produces <= 0) {
this.produceInvalidations.delete(key);
}
}
private async joinInflight<T>(inflight: Promise<unknown>): Promise<CacheJoinResult<T>> {
try {
return {joined: true, value: (await inflight) as T};
} catch (error) {
return {joined: false, error};
}
}
private async produceSingleFlight<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds: CacheTtlSeconds<T> | undefined,
generation: number,
produceTimeoutMs: number,
): Promise<T> {
const abandonment: CacheProduceAbandonment = {abandoned: false};
const produced = this.boundProduce(
this.produceAndStore(key, valueFactory, ttlSeconds, generation, abandonment),
abandonment,
produceTimeoutMs,
);
if (this.inflightValues.size >= CACHE_INFLIGHT_MAX_ENTRIES) {
return await produced;
}
const pending = produced.finally(() => {
this.inflightValues.delete(key);
});
this.inflightValues.set(key, pending);
return await pending;
}
private boundProduce<T>(
produced: Promise<T>,
abandonment: CacheProduceAbandonment,
produceTimeoutMs: number,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
abandonment.abandoned = true;
reject(new Error(CACHE_PRODUCE_TIMEOUT_MESSAGE));
}, produceTimeoutMs);
timer.unref?.();
produced.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
},
);
});
}
private async produceAndStore<T>(
key: string,
valueFactory: () => Promise<T>,
ttlSeconds: CacheTtlSeconds<T> | undefined,
generation: number,
abandonment: CacheProduceAbandonment,
): Promise<T> {
const value = await valueFactory();
if (!abandonment.abandoned && this.currentGeneration(key) === generation) {
await this.set(key, value, typeof ttlSeconds === 'function' ? ttlSeconds(value) : ttlSeconds);
}
return value;
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
function createRecordingProvider(): {
client: IKVProvider;
commands: Array<Array<string>>;
} {
const commands: Array<Array<string>> = [];
const client = {
set: async (key: string) => {
commands.push([key]);
return 'OK';
},
setex: async (key: string) => {
commands.push([key]);
},
isClustered: () => true,
pipeline: () => {
const keys: Array<string> = [];
commands.push(keys);
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => [],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return {client, commands};
}
describe('KVCacheProvider cluster hash slots', () => {
it('keeps a multi entry write off batched commands that span hash slots', async () => {
const {client, commands} = createRecordingProvider();
const provider = new KVCacheProvider({client});
expect(computeHashSlot('cache:alpha')).not.toBe(computeHashSlot('cache:beta'));
await provider.mset([
{key: 'cache:alpha', value: 1, ttlSeconds: 60},
{key: 'cache:beta', value: 2},
]);
expect(commands.flat().sort()).toEqual(['cache:alpha', 'cache:beta']);
expect(commands.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
});
});
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVPipeline, IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {describe, expect, it} from 'vitest';
const MAX_CONCURRENT_ROUND_TRIPS = 16;
interface RecordingProvider {
client: IKVProvider;
batches: Array<Array<string>>;
peakInFlight: number;
}
function createRecordingProvider(clustered: boolean): RecordingProvider {
const recorder: RecordingProvider = {
client: {} as IKVProvider,
batches: [],
peakInFlight: 0,
};
let inFlight = 0;
const trackRoundTrip = async (keys: Array<string>): Promise<void> => {
recorder.batches.push(keys);
inFlight += 1;
recorder.peakInFlight = Math.max(recorder.peakInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
inFlight -= 1;
};
recorder.client = {
isClustered: () => clustered,
set: async (key: string) => {
await trackRoundTrip([key]);
return 'OK';
},
setex: async (key: string) => {
await trackRoundTrip([key]);
},
pipeline: () => {
const keys: Array<string> = [];
const batch = {
set: (key: string) => {
keys.push(key);
return batch;
},
setex: (key: string) => {
keys.push(key);
return batch;
},
exec: async () => {
await trackRoundTrip(keys);
return [];
},
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
return recorder;
}
function createEntries(count: number): Array<{key: string; value: number; ttlSeconds: number}> {
return Array.from({length: count}, (_unused, index) => ({
key: `cache:entry:${index}`,
value: index,
ttlSeconds: 60,
}));
}
describe('KVCacheProvider multi entry write fan out', () => {
it('writes every entry in one round trip outside cluster mode', async () => {
const recorder = createRecordingProvider(false);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.batches.map((keys) => keys.length)).toEqual([1000]);
expect(recorder.peakInFlight).toBe(1);
});
it('surfaces a failed command inside a batched write', async () => {
const client = {
isClustered: () => false,
pipeline: () => {
const batch = {
set: () => batch,
setex: () => batch,
exec: async () => [[new Error('write rejected'), null]],
} as unknown as IKVPipeline;
return batch;
},
} as unknown as IKVProvider;
const provider = new KVCacheProvider({client});
await expect(provider.mset(createEntries(2))).rejects.toThrow('write rejected');
});
it('bounds concurrent round trips when entries span hash slots', async () => {
const recorder = createRecordingProvider(true);
const provider = new KVCacheProvider({client: recorder.client});
await provider.mset(createEntries(1000));
expect(recorder.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(recorder.batches.filter((keys) => new Set(keys.map(computeHashSlot)).size > 1)).toEqual([]);
expect(recorder.batches.flat().length).toBe(1000);
});
});
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {KVCacheProvider} from '@pkgs/cache/src/providers/KVCacheProvider';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {describe, expect, it, vi} from 'vitest';
function createKVCacheProvider(): {
provider: KVCacheProvider;
store: Map<string, string>;
ttls: Array<[string, number]>;
} {
const store = new Map<string, string>();
const ttls: Array<[string, number]> = [];
const client = {
get: async (key: string) => store.get(key) ?? null,
set: async (key: string, value: string) => {
store.set(key, value);
return 'OK';
},
setex: async (key: string, ttlSeconds: number, value: string) => {
ttls.push([key, ttlSeconds]);
store.set(key, value);
},
} as unknown as IKVProvider;
return {provider: new KVCacheProvider({client}), store, ttls};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('ICacheService.getOrSet', () => {
it('runs the factory once for concurrent callers on the same key', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => {
await delay(10);
return 7;
});
const results = await Promise.all([
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
]);
expect(results).toEqual([7, 7, 7]);
expect(factory).toHaveBeenCalledTimes(1);
await expect(cache.get('key')).resolves.toBe(7);
});
it('does not coalesce concurrent callers on different keys', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => {
await delay(10);
return 1;
});
await Promise.all([cache.getOrSet('a', factory), cache.getOrSet('b', factory)]);
expect(factory).toHaveBeenCalledTimes(2);
});
it('caches a null factory result and serves it as a hit', async () => {
const cache = new InMemoryProvider();
const factory = vi.fn(async () => null);
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
await expect(cache.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('serves a stored json null from the kv provider as a hit', async () => {
const {provider, store} = createKVCacheProvider();
const factory = vi.fn(async () => null);
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(store.get('key')).toBe('null');
await expect(provider.getOrSet<number | null>('key', factory, 60)).resolves.toBeNull();
expect(factory).toHaveBeenCalledTimes(1);
});
it('treats an unparseable stored value as a miss', async () => {
const {provider, store} = createKVCacheProvider();
store.set('key', '{not json');
const factory = vi.fn(async () => 3);
await expect(provider.getOrSet('key', factory, 60)).resolves.toBe(3);
expect(factory).toHaveBeenCalledTimes(1);
});
it('resolves the ttl from the produced value', async () => {
const {provider, store, ttls} = createKVCacheProvider();
const resolver = (value: number | null) => (value === null ? 5 : 30);
await expect(provider.getOrSet<number | null>('present', async () => 1, resolver)).resolves.toBe(1);
await expect(provider.getOrSet<number | null>('absent', async () => null, resolver)).resolves.toBeNull();
expect(ttls).toEqual([
['present', 30],
['absent', 5],
]);
expect(store.get('absent')).toBe('null');
});
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
const cache = new InMemoryProvider();
const failing = vi.fn(async () => {
await delay(10);
throw new Error('factory failed');
});
const settled = await Promise.allSettled([
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
cache.getOrSet('key', failing),
]);
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected', 'rejected', 'rejected']);
expect(failing).toHaveBeenCalledTimes(2);
await expect(cache.exists('key')).resolves.toBe(false);
const succeeding = vi.fn(async () => 11);
await expect(cache.getOrSet('key', succeeding)).resolves.toBe(11);
expect(succeeding).toHaveBeenCalledTimes(1);
});
it('does not fan a transient producer failure out to the callers that joined it', async () => {
const cache = new InMemoryProvider();
let calls = 0;
const factory = vi.fn(async () => {
calls += 1;
const attempt = calls;
await delay(10);
if (attempt === 1) {
throw new Error('transient failure');
}
return 11;
});
const settled = await Promise.allSettled([
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
cache.getOrSet('key', factory),
]);
expect(settled.map((result) => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled']);
expect(settled.filter((result) => result.status === 'fulfilled').map((result) => result.value)).toEqual([
11, 11, 11,
]);
expect(factory).toHaveBeenCalledTimes(2);
await expect(cache.get('key')).resolves.toBe(11);
});
});
@@ -0,0 +1,295 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
import {describe, expect, it, vi} from 'vitest';
const INFLIGHT_OVERFLOW_ENTRIES = 10000;
const PRODUCE_TIMEOUT_MS = 50;
const PRODUCE_TIMEOUT_MESSAGE = 'Cache produce timed out';
function deferred<T>(): {promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void} {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function settleWithin<T>(pending: Promise<T>, ms: number): Promise<T | 'pinned' | 'rejected'> {
return Promise.race([
pending.then(
(value) => value,
() => 'rejected' as const,
),
new Promise<'pinned'>((resolve) => setTimeout(() => resolve('pinned'), ms)),
]);
}
function trackedProduceKeys(cache: InMemoryProvider): Array<string> {
return [...(cache as unknown as {produceInvalidations: Map<string, unknown>}).produceInvalidations.keys()];
}
describe('cache invalidation during an in-flight produce', () => {
it('does not resurrect a value deleted while the factory was running', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await cache.delete('session');
gate.resolve('revoked-session');
await expect(pending).resolves.toBe('revoked-session');
expect(await cache.get('session')).toBeNull();
});
it('still stores the value when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
gate.resolve('live-session');
await pending;
expect(await cache.get('session')).toBe('live-session');
});
it('does not resurrect a value deleted while a retried produce was running', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
expect(gates).toHaveLength(2);
await cache.delete('session');
gates[1].resolve('fresh-after-delete');
await expect(joiner).resolves.toBe('fresh-after-delete');
expect(await cache.get('session')).toBeNull();
});
it('stores the value a retried produce built when no invalidation happens', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
});
it('stores the value a retried produce built after the first produce was invalidated', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30);
const joiner = cache.getOrSet('session', factory, 30);
await flush();
await cache.delete('session');
gates[0].reject(new Error('produce failed'));
await expect(producer).rejects.toThrow('produce failed');
await flush();
expect(gates).toHaveLength(2);
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
});
it('does not resurrect a value deleted after a concurrent caller released its produce', async () => {
const cache = new InMemoryProvider();
const gate = deferred<string>();
const pending = cache.getOrSet('session', async () => await gate.promise, 30);
await flush();
await cache.set('session', 'served-from-cache', 30);
await expect(cache.getOrSet('session', async () => 'unused', 30)).resolves.toBe('served-from-cache');
await cache.delete('session');
gate.resolve('stale-produce');
await expect(pending).resolves.toBe('stale-produce');
expect(await cache.get('session')).toBeNull();
});
it('does not resurrect a value deleted while a second overflow produce was running', async () => {
const cache = new InMemoryProvider();
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
const gate = deferred<string>();
fillers.push(gate);
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
}
await flush();
const first = deferred<string>();
const second = deferred<string>();
const firstProduce = cache.getOrSet('session', async () => await first.promise, 30);
const secondProduce = cache.getOrSet('session', async () => await second.promise, 30);
await flush();
first.resolve('first-produce');
await expect(firstProduce).resolves.toBe('first-produce');
await cache.delete('session');
second.resolve('second-produce');
await expect(secondProduce).resolves.toBe('second-produce');
expect(await cache.get('session')).toBeNull();
for (const gate of fillers) {
gate.resolve('filler');
}
await Promise.all(filling);
});
it('drops produce tracking once the last produce for a key settles', async () => {
const cache = new InMemoryProvider();
for (let index = 0; index < 50; index++) {
const gate = deferred<string>();
const pending = cache.getOrSet(`session:${index}`, async () => await gate.promise, 30);
await cache.delete(`session:${index}`);
gate.resolve('value');
await pending;
}
const shared = deferred<string>();
const producer = cache.getOrSet('shared', async () => await shared.promise, 30);
const joiner = cache.getOrSet('shared', async () => 'unused', 30);
await flush();
await cache.delete('shared');
shared.resolve('shared-value');
await Promise.all([producer, joiner]);
const failing = cache.getOrSet(
'failing',
async () => {
throw new Error('produce failed');
},
30,
);
await expect(failing).rejects.toThrow('produce failed');
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('does not pin a key forever when the factory never settles', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pinned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(pinned, 500)).resolves.toBe('rejected');
await expect(pinned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
const recovered = cache.getOrSet('session', async () => 'recovered', 30, PRODUCE_TIMEOUT_MS);
await expect(settleWithin(recovered, 500)).resolves.toBe('recovered');
stuck.resolve('never-settled');
await flush();
expect(await cache.get('session')).toBe('recovered');
});
it('does not store a value produced by a factory that settled after the timeout', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
stuck.resolve('late-produce');
await flush();
expect(await cache.get('session')).toBeNull();
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('retries once for the joiners when the producer times out', async () => {
const cache = new InMemoryProvider();
const gates: Array<ReturnType<typeof deferred<string>>> = [];
const factory = async () => {
const gate = deferred<string>();
gates.push(gate);
return await gate.promise;
};
const producer = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
const joiner = cache.getOrSet('session', factory, 30, PRODUCE_TIMEOUT_MS);
await expect(producer).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
await flush();
expect(gates).toHaveLength(2);
gates[1].resolve('retried-session');
await expect(joiner).resolves.toBe('retried-session');
expect(await cache.get('session')).toBe('retried-session');
gates[0].resolve('abandoned-produce');
await flush();
expect(await cache.get('session')).toBe('retried-session');
});
it('releases produce tracking when the factory never settles', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
await flush();
expect(trackedProduceKeys(cache)).toEqual([]);
});
it('stores a sibling produce that succeeded after an overflow produce timed out', async () => {
const cache = new InMemoryProvider();
const fillers: Array<ReturnType<typeof deferred<string>>> = [];
const filling: Array<Promise<string>> = [];
for (let index = 0; index < INFLIGHT_OVERFLOW_ENTRIES; index++) {
const gate = deferred<string>();
fillers.push(gate);
filling.push(cache.getOrSet(`filler:${index}`, async () => await gate.promise, 30));
}
await flush();
const stuck = deferred<string>();
const sibling = deferred<string>();
const abandoned = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
const succeeding = cache.getOrSet('session', async () => await sibling.promise, 30, PRODUCE_TIMEOUT_MS * 100);
await expect(abandoned).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
sibling.resolve('sibling-produce');
await expect(succeeding).resolves.toBe('sibling-produce');
expect(await cache.get('session')).toBe('sibling-produce');
for (const gate of fillers) {
gate.resolve('filler');
}
await Promise.all(filling);
});
it('does not hold the event loop open while a produce is in flight', async () => {
const cache = new InMemoryProvider();
const stuck = deferred<string>();
const timers: Array<NodeJS.Timeout> = [];
const scheduled = globalThis.setTimeout;
const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((handler: () => void, ms?: number) => {
const timer = scheduled(handler, ms);
if (ms === PRODUCE_TIMEOUT_MS) {
timers.push(timer);
}
return timer;
}) as typeof globalThis.setTimeout);
const pending = cache.getOrSet('session', async () => await stuck.promise, 30, PRODUCE_TIMEOUT_MS);
await flush();
spy.mockRestore();
expect(timers).toHaveLength(1);
expect(timers[0].hasRef()).toBe(false);
await expect(pending).rejects.toThrow(PRODUCE_TIMEOUT_MESSAGE);
});
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
const cache = new InMemoryProvider();
const first = deferred<string>();
const pending = cache.getOrSet('session', async () => await first.promise, 30);
await cache.delete('session');
first.resolve('stale');
await pending;
expect(await cache.get('session')).toBeNull();
await cache.getOrSet('session', async () => 'fresh', 30);
expect(await cache.get('session')).toBe('fresh');
});
});
+6 -6
View File
@@ -6,7 +6,7 @@ import {
validateLockKey,
validateLockToken,
} from '@pkgs/cache/src/CacheLockValidation';
import {ICacheService} from '@pkgs/cache/src/ICacheService';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
interface CacheEntry<T> {
value: T;
@@ -67,14 +67,14 @@ export class InMemoryProvider extends ICacheService {
}
}
async get<T>(key: string): Promise<T | null> {
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (!entry) return {hit: false};
if (this.isExpired(entry)) {
this.cache.delete(key);
return null;
return {hit: false};
}
return entry.value;
return {hit: true, value: entry.value};
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
@@ -86,7 +86,7 @@ export class InMemoryProvider extends ICacheService {
this.cache.set(key, entry);
}
async delete(key: string): Promise<void> {
protected async deleteEntry(key: string): Promise<void> {
this.cache.delete(key);
}
+29 -38
View File
@@ -8,9 +8,10 @@ import {
validateLockToken,
} from '@pkgs/cache/src/CacheLockValidation';
import type {CacheLogger, CacheTelemetry} from '@pkgs/cache/src/CacheProviderTypes';
import {safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
import {ICacheService} from '@pkgs/cache/src/ICacheService';
import {parseCachedValue, safeJsonParse, serializeValue} from '@pkgs/cache/src/CacheSerialization';
import {type CacheLookupResult, ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
interface KVCacheProviderConfig {
client: IKVProvider;
@@ -69,16 +70,16 @@ export class KVCacheProvider extends ICacheService {
}
}
async get<T>(key: string): Promise<T | null> {
async getEntry<T>(key: string): Promise<CacheLookupResult<T>> {
return this.instrumented(
'get',
key,
async () => {
async (): Promise<CacheLookupResult<T>> => {
const value = await this.client.get(key);
if (value == null) return null;
return safeJsonParse<T>(value, this.logger);
if (value == null) return {hit: false};
return parseCachedValue<T>(value, this.logger);
},
(result) => (result == null ? 'miss' : 'hit'),
(result) => (result.hit ? 'hit' : 'miss'),
);
}
@@ -93,7 +94,7 @@ export class KVCacheProvider extends ICacheService {
});
}
async delete(key: string): Promise<void> {
protected async deleteEntry(key: string): Promise<void> {
return this.instrumented('delete', key, async () => {
await this.client.del(key);
});
@@ -137,37 +138,27 @@ export class KVCacheProvider extends ICacheService {
}>,
): Promise<void> {
if (entries.length === 0) return;
const withoutTtl: Array<{
key: string;
value: T;
}> = [];
const withTtl: Array<{
key: string;
value: T;
ttlSeconds: number;
}> = [];
for (const entry of entries) {
if (entry.ttlSeconds) {
withTtl.push({
key: entry.key,
value: entry.value,
ttlSeconds: entry.ttlSeconds,
});
} else {
withoutTtl.push({
key: entry.key,
value: entry.value,
});
const serialized = entries.map((entry) => ({
key: entry.key,
value: serializeValue(entry.value),
ttlSeconds: entry.ttlSeconds,
}));
const batches = splitIntoSlotBatches(serialized, (entry) => entry.key, this.client.isClustered());
await runSlotBatches(batches, async (batch) => {
const pipeline = this.client.pipeline();
for (const entry of batch) {
if (entry.ttlSeconds) {
pipeline.setex(entry.key, entry.ttlSeconds, entry.value);
} else {
pipeline.set(entry.key, entry.value);
}
}
}
const pipeline = this.client.pipeline();
for (const entry of withoutTtl) {
pipeline.set(entry.key, serializeValue(entry.value));
}
for (const entry of withTtl) {
pipeline.setex(entry.key, entry.ttlSeconds, serializeValue(entry.value));
}
await pipeline.exec();
for (const [error] of await pipeline.exec()) {
if (error) {
throw error;
}
}
});
}
async deletePattern(pattern: string): Promise<number> {
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+13 -1
View File
@@ -6,6 +6,12 @@ import cassandra from 'cassandra-driver';
const distance = cassandra.types.distance;
const MAX_REQUESTS_PER_CONNECTION = 2048;
const CONNECT_TIMEOUT_MS = 5000;
const DEFAULT_READ_TIMEOUT_MS = 5000;
export const BACKGROUND_READ_TIMEOUT_MS = 12000;
interface CassandraConfig {
hosts: Array<string>;
port?: number | undefined;
@@ -13,6 +19,7 @@ interface CassandraConfig {
localDc: string;
username?: string | undefined;
password?: string | undefined;
readTimeoutMs?: number | undefined;
}
interface CassandraClientOptions {
@@ -63,6 +70,7 @@ class CassandraClient implements ICassandraClient {
localDc: config.localDc,
username: config.username,
password: config.password,
readTimeoutMs: config.readTimeoutMs,
};
this.logger = options.logger ?? NoopLogger;
this.client = null;
@@ -83,12 +91,16 @@ class CassandraClient implements ICassandraClient {
port: this.config.port ?? 9042,
},
pooling: {
maxRequestsPerConnection: 32768,
maxRequestsPerConnection: MAX_REQUESTS_PER_CONNECTION,
coreConnectionsPerHost: {
[distance.local]: 4,
[distance.remote]: 2,
},
},
socketOptions: {
connectTimeout: CONNECT_TIMEOUT_MS,
readTimeout: this.config.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS,
},
encoding: {
map: Map,
set: Set,
+2 -1
View File
@@ -12,7 +12,8 @@
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@fluxer/constants": "workspace:*"
"@fluxer/constants": "workspace:*",
"undici": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
+17 -2
View File
@@ -83,6 +83,7 @@ function createFetchInit(
headers: Record<string, string>,
body: string | undefined,
signal: AbortSignal,
dispatcher: NonNullable<RequestInit['dispatcher']> | undefined,
): RequestInit {
return {
method,
@@ -90,9 +91,16 @@ function createFetchInit(
body,
signal,
redirect: 'manual',
...(dispatcher ? {dispatcher} : {}),
};
}
function resolveRequestUrlPolicyDispatcher(
requestUrlPolicy: RequestUrlPolicy | undefined,
): NonNullable<RequestInit['dispatcher']> | undefined {
return (requestUrlPolicy as {dispatcher?: NonNullable<RequestInit['dispatcher']>} | undefined)?.dispatcher;
}
function isRedirectStatus(status: number): boolean {
return REDIRECT_STATUS_CODES.includes(status as (typeof REDIRECT_STATUS_CODES)[number]);
}
@@ -142,11 +150,15 @@ async function fetchWithRedirects(
let currentMethod: HttpMethod = method;
let currentBody = body;
let currentHeaders = {...headers};
const dispatcher = resolveRequestUrlPolicyDispatcher(requestUrlPolicy);
await validateRequestUrlPolicy(requestUrlPolicy, currentUrl, {
phase: 'initial',
redirectCount: 0,
});
let response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
let response = await fetch(
currentUrl.href,
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
);
let redirectCount = 0;
while (isRedirectStatus(response.status)) {
if (redirectCount >= maxRedirects) {
@@ -174,7 +186,10 @@ async function fetchWithRedirects(
previousUrl: previousUrl.href,
});
currentUrl = nextUrl;
response = await fetch(currentUrl.href, createFetchInit(currentMethod, currentHeaders, currentBody, signal));
response = await fetch(
currentUrl.href,
createFetchInit(currentMethod, currentHeaders, currentBody, signal, dispatcher),
);
redirectCount = nextRedirectCount;
}
return response;
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import dns from 'node:dns';
import type {LookupFunction} from 'node:net';
import {BlockList, isIP} from 'node:net';
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
import {HttpError} from '@pkgs/http_client/src/HttpError';
import {Agent} from 'undici';
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
@@ -89,29 +91,77 @@ function isFqdnHostname(hostname: string): boolean {
return !/^\d+$/.test(topLevelDomain);
}
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
if (!normalized.startsWith('::ffff:')) {
if (isIP(normalized) !== 6) {
return null;
}
const suffix = normalized.slice('::ffff:'.length);
if (isIP(suffix) === 4) {
return suffix;
let head = normalized;
let embeddedIpv4Octets: Array<number> | null = null;
const lastColonIndex = head.lastIndexOf(':');
const trailing = head.slice(lastColonIndex + 1);
if (trailing.includes('.')) {
if (isIP(trailing) !== 4) {
return null;
}
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
}
const groups = suffix.split(':');
if (groups.length !== 2) {
let groups: Array<string>;
if (head.indexOf('::') === -1) {
groups = head.split(':');
if (groups.length !== 8) {
return null;
}
} else {
const [beforePart, afterPart] = head.split('::');
const before = beforePart.length > 0 ? beforePart.split(':') : [];
const after = afterPart.length > 0 ? afterPart.split(':') : [];
const missing = 8 - before.length - after.length;
if (missing < 1) {
return null;
}
groups = [...before, ...new Array(missing).fill('0'), ...after];
}
const bytes = new Uint8Array(16);
for (let index = 0; index < 8; index += 1) {
const value = parseHexGroup(groups[index]);
if (value === null) {
return null;
}
bytes[index * 2] = (value >> 8) & 0xff;
bytes[index * 2 + 1] = value & 0xff;
}
if (embeddedIpv4Octets) {
bytes[12] = embeddedIpv4Octets[0];
bytes[13] = embeddedIpv4Octets[1];
bytes[14] = embeddedIpv4Octets[2];
bytes[15] = embeddedIpv4Octets[3];
}
return bytes;
}
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
const bytes = expandIpv6ToBytes(ipv6Address);
if (!bytes) {
return null;
}
const high = parseHexGroup(groups[0]);
const low = parseHexGroup(groups[1]);
if (high === null || low === null) {
return null;
const hasPrefix = (prefix: Array<number>): boolean => prefix.every((byte, index) => bytes[index] === byte);
const dottedQuadAt = (start: number): string =>
`${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`;
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) {
return dottedQuadAt(12);
}
const octet1 = (high >> 8) & 0xff;
const octet2 = high & 0xff;
const octet3 = (low >> 8) & 0xff;
const octet4 = low & 0xff;
return `${octet1}.${octet2}.${octet3}.${octet4}`;
if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0x20, 0x02])) {
return dottedQuadAt(2);
}
return null;
}
function parseHexGroup(value: string | undefined): number | null {
@@ -128,15 +178,29 @@ function isBlockedIpAddress(address: string): boolean {
return blockedIpv4List.check(normalizedAddress, 'ipv4');
}
if (family === 6) {
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
if (mappedIpv4) {
return blockedIpv4List.check(mappedIpv4, 'ipv4');
const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress);
if (embeddedIpv4) {
return blockedIpv4List.check(embeddedIpv4, 'ipv4');
}
return blockedIpv6List.check(normalizedAddress, 'ipv6');
}
return true;
}
export function isPubliclyRoutableUrlShape(url: URL): boolean {
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
return false;
}
const normalizedHostname = normalizeHostname(url.hostname);
if (!normalizedHostname) {
return false;
}
if (isIP(normalizedHostname)) {
return !isBlockedIpAddress(normalizedHostname);
}
return isFqdnHostname(normalizedHostname);
}
function getPolicyErrorContext(context: RequestUrlValidationContext): string {
if (context.phase === 'redirect') {
const previous = context.previousUrl ?? 'unknown';
@@ -168,9 +232,43 @@ function deduplicateAddresses(addresses: Array<string>): Array<string> {
return deduplicated;
}
function createBlocklistDispatcher(allowPrivateAddresses: boolean): NonNullable<RequestInit['dispatcher']> {
const lookup: LookupFunction = (hostname, options, callback) => {
dns.lookup(hostname, {...options, all: true, verbatim: true}, (error, addresses) => {
if (error) {
callback(error, []);
return;
}
if (!allowPrivateAddresses && addresses.some((entry) => isBlockedIpAddress(entry.address))) {
callback(new Error(`Hostname ${hostname} resolved to a disallowed address`), []);
return;
}
if (options.all) {
callback(null, addresses);
return;
}
const [primary] = addresses;
if (!primary) {
callback(new Error(`Hostname ${hostname} resolved to no IP addresses`), []);
return;
}
callback(null, primary.address, primary.family);
});
};
return new Agent({
connect: {
lookup,
},
}) as unknown as NonNullable<RequestInit['dispatcher']>;
}
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
dispatcher: NonNullable<RequestInit['dispatcher']>;
}
export function createPublicInternetRequestUrlPolicy(
options?: PublicInternetRequestUrlPolicyOptions,
): RequestUrlPolicy {
): PublicInternetRequestUrlPolicy {
const dnsCacheTtlMs =
typeof options?.dnsCacheTtlMs === 'number' && options.dnsCacheTtlMs > 0
? options.dnsCacheTtlMs
@@ -221,5 +319,6 @@ export function createPublicInternetRequestUrlPolicy(
}
}
}
return {validate};
const dispatcher = createBlocklistDispatcher(allowPrivateAddresses);
return {validate, dispatcher};
}
+5 -1
View File
@@ -7,6 +7,8 @@
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
@@ -16,6 +18,8 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
+3 -1
View File
@@ -88,7 +88,8 @@ export interface IKVProvider {
refillIntervalMs: number,
): Promise<number>;
scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void>;
removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean>;
claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean>;
removeBulkDeletion(queueKey: string, secondaryKey: string, member?: string): Promise<boolean>;
scan(pattern: string, count: number): Promise<Array<string>>;
dequeuePurgeBatch(
queueKey: string,
@@ -103,5 +104,6 @@ export interface IKVProvider {
}>;
pipeline(): IKVPipeline;
multi(): IKVPipeline;
isClustered(): boolean;
health(): Promise<boolean>;
}
+101 -6
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import type {IKVPipeline, IKVProvider, IKVSubscription, KVRateLimitResult} from '@pkgs/kv_client/src/IKVProvider';
import {
type IKVLogger,
@@ -15,6 +16,7 @@ import {
parseRangeByScoreArguments,
parseSetArguments,
} from '@pkgs/kv_client/src/KVCommandArguments';
import {runSlotBatches, splitIntoSlotBatches} from '@pkgs/kv_client/src/KVHashSlots';
import {KVPipeline} from '@pkgs/kv_client/src/KVPipeline';
import {KVSubscription} from '@pkgs/kv_client/src/KVSubscription';
import Redis, {Cluster} from 'ioredis';
@@ -222,7 +224,23 @@ tokens = tokens - #urls
redis.call('SET', bucketKey, cjson.encode({tokens = tokens, lastRefill = lastRefill}), 'EX', 3600)
return cjson.encode({urls = urls, tokens = #urls})
`;
const CLAIM_BULK_DELETION_SCRIPT = `
local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
if not score then
return 0
end
if tonumber(score) > tonumber(ARGV[2]) then
return 0
end
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[1])
return 1
`;
const REMOVE_BULK_DELETION_SCRIPT = `
local member = ARGV[1]
if member ~= '' and redis.call('ZREM', KEYS[1], member) == 1 then
redis.call('DEL', KEYS[2])
return 1
end
local value = redis.call('GET', KEYS[2])
if not value then
return 0
@@ -232,6 +250,8 @@ redis.call('DEL', KEYS[2])
return 1
`;
const SCRIPT_SHA_CACHE = new Map<string, string>();
interface ScriptPurgeBatchResult {
urls: Array<string>;
tokens: number;
@@ -336,7 +356,23 @@ export class KVClient implements IKVProvider {
}
async mget(...keys: Array<string>): Promise<Array<string | null>> {
return await this.execute('mget', async () => this.client.mget(...keys));
if (keys.length === 0) {
return [];
}
return await this.execute('mget', async () => {
const values = new Array<string | null>(keys.length).fill(null);
const batches = this.splitBySlot(
keys.map((key, index) => ({key, index})),
(entry) => entry.key,
);
await runSlotBatches(batches, async (batch) => {
const batchValues = await this.client.mget(...batch.map((entry) => entry.key));
for (const [position, entry] of batch.entries()) {
values[entry.index] = batchValues[position] ?? null;
}
});
return values;
});
}
async mset(...args: Array<string>): Promise<void> {
@@ -344,9 +380,11 @@ export class KVClient implements IKVProvider {
if (entries.length === 0) {
return;
}
const pairs = entries.flatMap((entry) => [entry.key, entry.value]);
await this.execute('mset', async () => {
await this.client.mset(...pairs);
const batches = this.splitBySlot(entries, (entry) => entry.key);
await runSlotBatches(batches, async (batch) => {
await this.client.mset(...batch.flatMap((entry) => [entry.key, entry.value]));
});
});
}
@@ -354,7 +392,14 @@ export class KVClient implements IKVProvider {
if (keys.length === 0) {
return 0;
}
return await this.execute('del', async () => this.client.del(...keys));
return await this.execute('del', async () => {
const deleted: Array<number> = [];
const batches = this.splitBySlot(keys, (key) => key);
await runSlotBatches(batches, async (batch) => {
deleted.push(await this.client.del(...batch));
});
return deleted.reduce((total, count) => total + count, 0);
});
}
async exists(key: string): Promise<number> {
@@ -605,13 +650,27 @@ export class KVClient implements IKVProvider {
);
}
async removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean> {
async claimBulkDeletion(queueKey: string, member: string, maxScore: number, leaseScore: number): Promise<boolean> {
const result = await this.executeScript(
'claimBulkDeletion',
CLAIM_BULK_DELETION_SCRIPT,
1,
queueKey,
member,
maxScore,
leaseScore,
);
return Number(result) === 1;
}
async removeBulkDeletion(queueKey: string, secondaryKey: string, member = ''): Promise<boolean> {
const result = await this.executeScript(
'removeBulkDeletion',
REMOVE_BULK_DELETION_SCRIPT,
2,
queueKey,
secondaryKey,
member,
);
return Number(result) === 1;
}
@@ -669,6 +728,14 @@ export class KVClient implements IKVProvider {
});
}
isClustered(): boolean {
return this.config.mode === 'cluster';
}
private splitBySlot<T>(items: ReadonlyArray<T>, keyOf: (item: T) => string): Array<Array<T>> {
return splitIntoSlotBatches(items, keyOf, this.isClustered());
}
pipeline(): IKVPipeline {
return new KVPipeline({
createCommander: () => this.client.pipeline(),
@@ -699,7 +766,18 @@ export class KVClient implements IKVProvider {
keyCount: number,
...args: Array<string | number>
): Promise<unknown> {
return await this.execute(command, async () => this.client.eval(script, keyCount, ...args));
return await this.execute(command, async () => this.evalCachedScript(script, keyCount, args));
}
private async evalCachedScript(script: string, keyCount: number, args: Array<string | number>): Promise<unknown> {
try {
return await this.client.evalsha(getScriptSha(script), keyCount, ...args);
} catch (error) {
if (!isNoScriptError(error)) {
throw error;
}
return await this.client.eval(script, keyCount, ...args);
}
}
private async executeJsonScript<T>(
@@ -795,6 +873,23 @@ function normalizeRateLimitResult(result: KVRateLimitResult): KVRateLimitResult
};
}
function getScriptSha(script: string): string {
const cached = SCRIPT_SHA_CACHE.get(script);
if (cached !== undefined) {
return cached;
}
const sha = createHash('sha1').update(script).digest('hex');
SCRIPT_SHA_CACHE.set(script, sha);
return sha;
}
function isNoScriptError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return error.message.includes('NOSCRIPT');
}
function isTimeoutError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const HASH_SLOT_COUNT = 16384;
const MAX_CONCURRENT_SLOT_BATCHES = 16;
function extractHashTag(key: string): string {
const start = key.indexOf('{');
if (start === -1) {
return key;
}
const end = key.indexOf('}', start + 1);
if (end > start + 1) {
return key.slice(start + 1, end);
}
return key;
}
export function computeHashSlot(key: string): number {
const hashed = extractHashTag(key);
let crc = 0;
for (let index = 0; index < hashed.length; index += 1) {
crc ^= (hashed.charCodeAt(index) & 0xff) << 8;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 0x8000) === 0 ? (crc << 1) & 0xffff : ((crc << 1) ^ 0x1021) & 0xffff;
}
}
return crc % HASH_SLOT_COUNT;
}
export function splitIntoSlotBatches<T>(
items: ReadonlyArray<T>,
keyOf: (item: T) => string,
clustered: boolean,
): Array<Array<T>> {
if (items.length === 0) {
return [];
}
if (!clustered) {
return [[...items]];
}
const batches = new Map<number, Array<T>>();
for (const item of items) {
const slot = computeHashSlot(keyOf(item));
const batch = batches.get(slot);
if (batch) {
batch.push(item);
} else {
batches.set(slot, [item]);
}
}
return [...batches.values()];
}
export async function runSlotBatches<T>(batches: ReadonlyArray<T>, run: (batch: T) => Promise<void>): Promise<void> {
if (batches.length <= MAX_CONCURRENT_SLOT_BATCHES) {
await Promise.all(batches.map(async (batch) => await run(batch)));
return;
}
let nextIndex = 0;
const workers = Array.from({length: MAX_CONCURRENT_SLOT_BATCHES}, async () => {
while (nextIndex < batches.length) {
const batch = batches[nextIndex];
nextIndex += 1;
await run(batch);
}
});
await Promise.all(workers);
}
@@ -0,0 +1,215 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {KVClient} from '@pkgs/kv_client/src/KVClient';
import {KVClientErrorCode} from '@pkgs/kv_client/src/KVClientError';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {evalshaMock, evalMock} = vi.hoisted(() => ({
evalshaMock: vi.fn(),
evalMock: vi.fn(),
}));
vi.mock('ioredis', () => {
class MockRedis {
evalsha = evalshaMock;
eval = evalMock;
}
return {default: MockRedis, Cluster: MockRedis};
});
const RATE_LIMIT_REPLY = JSON.stringify({
allowed: true,
limit: 5,
remaining: 4,
resetAfterMs: 200,
resetAtMs: 1717171717,
retryAfterMs: 0,
});
const EXPECTED_RATE_LIMIT_RESULT = {
allowed: true,
limit: 5,
remaining: 4,
resetAfterMs: 200,
resetAtMs: 1717171717,
retryAfterMs: 0,
};
function createClient(): KVClient {
return new KVClient('redis://127.0.0.1:6379');
}
function noScriptError(): Error {
return new Error('NOSCRIPT No matching script. Please use EVAL.');
}
function getCallArguments(mock: typeof evalshaMock, index: number): Array<unknown> {
const call = mock.mock.calls[index];
if (!call) {
throw new Error(`Expected a call at index ${index}`);
}
return call;
}
describe('KVClient script execution', () => {
beforeEach(() => {
evalshaMock.mockReset();
evalMock.mockReset();
});
it('sends EVALSHA instead of EVAL for the leaky bucket rate limit script', async () => {
evalshaMock.mockResolvedValue(RATE_LIMIT_REPLY);
const result = await createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
expect(evalMock).not.toHaveBeenCalled();
expect(evalshaMock).toHaveBeenCalledTimes(1);
const [sha, keyCount, key, , limit, windowMs, cost] = getCallArguments(evalshaMock, 0);
expect(sha).toMatch(/^[0-9a-f]{40}$/);
expect(keyCount).toBe(1);
expect(key).toBe('rate_limit:bucket');
expect(limit).toBe(5);
expect(windowMs).toBe(1000);
expect(cost).toBe(1);
expect(result).toEqual(EXPECTED_RATE_LIMIT_RESULT);
});
it('falls back to EVAL with the original script and identical arguments on NOSCRIPT', async () => {
evalshaMock.mockRejectedValue(noScriptError());
evalMock.mockResolvedValue(RATE_LIMIT_REPLY);
const result = await createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
expect(evalshaMock).toHaveBeenCalledTimes(1);
expect(evalMock).toHaveBeenCalledTimes(1);
const [sha, ...evalshaRest] = getCallArguments(evalshaMock, 0);
const [script, ...evalRest] = getCallArguments(evalMock, 0);
expect(createHash('sha1').update(String(script)).digest('hex')).toBe(sha);
expect(evalRest).toEqual(evalshaRest);
expect(String(script)).toContain("local rawState = redis.call('GET', key)");
expect(result).toEqual(EXPECTED_RATE_LIMIT_RESULT);
});
it('keeps using EVALSHA with the same digest after a NOSCRIPT fallback', async () => {
evalshaMock.mockRejectedValueOnce(noScriptError()).mockResolvedValue(RATE_LIMIT_REPLY);
evalMock.mockResolvedValue(RATE_LIMIT_REPLY);
const client = createClient();
const first = await client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
const second = await client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1);
expect(evalshaMock).toHaveBeenCalledTimes(2);
expect(evalMock).toHaveBeenCalledTimes(1);
expect(getCallArguments(evalshaMock, 1)[0]).toBe(getCallArguments(evalshaMock, 0)[0]);
expect(first).toEqual(EXPECTED_RATE_LIMIT_RESULT);
expect(second).toEqual(EXPECTED_RATE_LIMIT_RESULT);
});
it('sends EVALSHA for every scripted command', async () => {
const cases: Array<{name: string; reply: unknown; keyCount: number; run: (client: KVClient) => Promise<unknown>}> =
[
{
name: 'releaseLock',
reply: 1,
keyCount: 1,
run: async (client) => client.releaseLock('lock:key', 'token'),
},
{
name: 'extendLock',
reply: 1,
keyCount: 1,
run: async (client) => client.extendLock('lock:key', 'token', 30),
},
{
name: 'renewSnowflakeNode',
reply: 1,
keyCount: 1,
run: async (client) => client.renewSnowflakeNode('snowflake:1', 'instance', 30),
},
{
name: 'checkLeakyBucketLimit',
reply: RATE_LIMIT_REPLY,
keyCount: 1,
run: async (client) => client.checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1),
},
{
name: 'tryConsumeTokens',
reply: 2,
keyCount: 1,
run: async (client) => client.tryConsumeTokens('tokens:key', 2, 10, 1, 1000),
},
{
name: 'scheduleBulkDeletion',
reply: 1,
keyCount: 2,
run: async (client) => client.scheduleBulkDeletion('queue:key', 'secondary:key', 1, 'value'),
},
{
name: 'claimBulkDeletion',
reply: 1,
keyCount: 1,
run: async (client) => client.claimBulkDeletion('queue:key', 'member', 1, 2),
},
{
name: 'removeBulkDeletion',
reply: 1,
keyCount: 2,
run: async (client) => client.removeBulkDeletion('queue:key', 'secondary:key'),
},
{
name: 'dequeuePurgeBatch',
reply: JSON.stringify({urls: ['https://fluxer.test/a.png'], tokens: 1}),
keyCount: 2,
run: async (client) => client.dequeuePurgeBatch('queue:key', 'bucket:key', 10, 10, 1, 1000),
},
{
name: 'evalScript',
reply: 1,
keyCount: 1,
run: async (client) => client.evalScript('customScript', "return redis.call('GET', KEYS[1])", 1, 'key'),
},
];
const digests = new Set<unknown>();
for (const scriptCase of cases) {
evalshaMock.mockReset();
evalMock.mockReset();
evalshaMock.mockResolvedValue(scriptCase.reply);
await scriptCase.run(createClient());
expect(evalMock, scriptCase.name).not.toHaveBeenCalled();
expect(evalshaMock, scriptCase.name).toHaveBeenCalledTimes(1);
const [sha, keyCount] = getCallArguments(evalshaMock, 0);
expect(sha, scriptCase.name).toMatch(/^[0-9a-f]{40}$/);
expect(keyCount, scriptCase.name).toBe(scriptCase.keyCount);
digests.add(sha);
}
expect(digests.size).toBe(cases.length);
});
it('does not retry with EVAL when the script fails for another reason', async () => {
evalshaMock.mockRejectedValue(new Error('Connection is closed.'));
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
code: KVClientErrorCode.REQUEST_FAILED,
message: 'KV request failed (releaseLock): Connection is closed.',
});
expect(evalMock).not.toHaveBeenCalled();
});
it('normalizes timeouts raised by the EVALSHA attempt', async () => {
evalshaMock.mockRejectedValue(new Error('Command timed out'));
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
code: KVClientErrorCode.TIMEOUT,
message: 'KV request timed out: releaseLock',
});
});
it('normalizes failures raised by the EVAL fallback', async () => {
evalshaMock.mockRejectedValue(noScriptError());
evalMock.mockRejectedValue(new Error('Connection is closed.'));
await expect(createClient().releaseLock('lock:key', 'token')).rejects.toMatchObject({
code: KVClientErrorCode.REQUEST_FAILED,
message: 'KV request failed (releaseLock): Connection is closed.',
});
});
it('reports invalid JSON from a scripted command', async () => {
evalshaMock.mockResolvedValue('not json');
await expect(createClient().checkLeakyBucketLimit('rate_limit:bucket', 5, 1000, 1)).rejects.toMatchObject({
code: KVClientErrorCode.INVALID_RESPONSE,
});
});
});
@@ -0,0 +1,110 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVClient} from '@pkgs/kv_client/src/KVClient';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const {commands, store} = vi.hoisted(() => ({
commands: [] as Array<{name: string; keys: Array<string>}>,
store: new Map<string, string>(),
}));
vi.mock('ioredis', () => {
class MockRedis {
async get(key: string): Promise<string | null> {
commands.push({name: 'get', keys: [key]});
return store.get(key) ?? null;
}
async set(key: string, value: string): Promise<string> {
commands.push({name: 'set', keys: [key]});
store.set(key, value);
return 'OK';
}
async del(...keys: Array<string>): Promise<number> {
commands.push({name: 'del', keys});
return keys.filter((key) => store.delete(key)).length;
}
async mget(...keys: Array<string>): Promise<Array<string | null>> {
commands.push({name: 'mget', keys});
return keys.map((key) => store.get(key) ?? null);
}
async mset(...args: Array<string>): Promise<string> {
const keys: Array<string> = [];
for (let index = 0; index + 1 < args.length; index += 2) {
keys.push(args[index]);
store.set(args[index], args[index + 1]);
}
commands.push({name: 'mset', keys});
return 'OK';
}
}
return {default: MockRedis, Cluster: MockRedis};
});
function crossSlotCommands(): Array<{name: string; keys: Array<string>}> {
return commands.filter((command) => new Set(command.keys.map(computeHashSlot)).size > 1);
}
function createClusteredClient(): KVClient {
return new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
}
function createStandaloneClient(): KVClient {
return new KVClient({url: 'redis://127.0.0.1:6379', mode: 'standalone'});
}
describe('KVClient cluster hash slots', () => {
beforeEach(() => {
commands.length = 0;
store.clear();
});
it('reads several keys without a command spanning hash slots', async () => {
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
const client = createClusteredClient();
await client.set('slot:alpha', 'one');
await expect(client.mget('slot:alpha', 'slot:beta')).resolves.toEqual(['one', null]);
expect(crossSlotCommands()).toEqual([]);
});
it('writes several keys without a command spanning hash slots', async () => {
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
const client = createClusteredClient();
await client.mset('slot:alpha', 'one', 'slot:beta', 'two');
expect(store.get('slot:alpha')).toBe('one');
expect(store.get('slot:beta')).toBe('two');
expect(crossSlotCommands()).toEqual([]);
});
it('deletes several keys without a command spanning hash slots', async () => {
expect(computeHashSlot('slot:alpha')).not.toBe(computeHashSlot('slot:beta'));
const client = createClusteredClient();
await client.set('slot:alpha', 'one');
await client.set('slot:beta', 'two');
await expect(client.del('slot:alpha', 'slot:beta', 'slot:gamma')).resolves.toBe(2);
expect(store.size).toBe(0);
expect(crossSlotCommands()).toEqual([]);
});
it('keeps multi key commands whole outside cluster mode', async () => {
const client = createStandaloneClient();
await client.mset('slot:alpha', 'one', 'slot:beta', 'two');
await expect(client.mget('slot:alpha', 'slot:beta')).resolves.toEqual(['one', 'two']);
await expect(client.del('slot:alpha', 'slot:beta')).resolves.toBe(2);
expect(commands).toEqual([
{name: 'mset', keys: ['slot:alpha', 'slot:beta']},
{name: 'mget', keys: ['slot:alpha', 'slot:beta']},
{name: 'del', keys: ['slot:alpha', 'slot:beta']},
]);
});
});
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {KVClient} from '@pkgs/kv_client/src/KVClient';
import {computeHashSlot} from '@pkgs/kv_client/src/KVHashSlots';
import {beforeEach, describe, expect, it, vi} from 'vitest';
const MAX_CONCURRENT_ROUND_TRIPS = 16;
const {commands, store, tracker} = vi.hoisted(() => ({
commands: [] as Array<{name: string; keys: Array<string>}>,
store: new Map<string, string>(),
tracker: {inFlight: 0, peakInFlight: 0},
}));
vi.mock('ioredis', () => {
const trackRoundTrip = async (name: string, keys: Array<string>): Promise<void> => {
commands.push({name, keys});
tracker.inFlight += 1;
tracker.peakInFlight = Math.max(tracker.peakInFlight, tracker.inFlight);
await new Promise((resolve) => setTimeout(resolve, 0));
tracker.inFlight -= 1;
};
class MockRedis {
async mget(...keys: Array<string>): Promise<Array<string | null>> {
await trackRoundTrip('mget', keys);
return keys.map((key) => store.get(key) ?? null);
}
async mset(...args: Array<string>): Promise<string> {
const keys: Array<string> = [];
for (let index = 0; index + 1 < args.length; index += 2) {
keys.push(args[index]);
store.set(args[index], args[index + 1]);
}
await trackRoundTrip('mset', keys);
return 'OK';
}
async del(...keys: Array<string>): Promise<number> {
await trackRoundTrip('del', keys);
return keys.length;
}
async get(key: string): Promise<string | null> {
await trackRoundTrip('get', [key]);
return store.get(key) ?? null;
}
async set(key: string, value: string): Promise<string> {
await trackRoundTrip('set', [key]);
store.set(key, value);
return 'OK';
}
}
return {default: MockRedis, Cluster: MockRedis};
});
function createKeys(count: number): Array<string> {
return Array.from({length: count}, (_unused, index) => `fanout:key:${index}`);
}
function crossSlotCommands(): Array<{name: string; keys: Array<string>}> {
return commands.filter((command) => new Set(command.keys.map(computeHashSlot)).size > 1);
}
describe('KVClient multi key fan out', () => {
beforeEach(() => {
commands.length = 0;
store.clear();
tracker.inFlight = 0;
tracker.peakInFlight = 0;
});
it('bounds concurrent round trips for a cluster read', async () => {
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
const values = await client.mget(...createKeys(1000));
expect(values.length).toBe(1000);
expect(tracker.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(crossSlotCommands()).toEqual([]);
});
it('bounds concurrent round trips for a cluster write', async () => {
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
await client.mset(...createKeys(1000).flatMap((key) => [key, 'value']));
expect(tracker.peakInFlight).toBeLessThanOrEqual(MAX_CONCURRENT_ROUND_TRIPS);
expect(crossSlotCommands()).toEqual([]);
});
it('reads a thousand keys in one round trip outside cluster mode', async () => {
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'standalone'});
await client.mget(...createKeys(1000));
expect(commands.map((command) => ({name: command.name, count: command.keys.length}))).toEqual([
{name: 'mget', count: 1000},
]);
expect(tracker.peakInFlight).toBe(1);
});
it('keeps values ordered when a cluster read is split by slot', async () => {
const client = new KVClient({url: 'redis://127.0.0.1:6379', mode: 'cluster'});
await client.mset('fanout:a', 'one', 'fanout:b', 'two');
const values = await client.mget('fanout:a', 'fanout:missing', 'fanout:b');
expect(values).toEqual(['one', null, 'two']);
});
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});
+20 -3
View File
@@ -14,10 +14,15 @@ interface PostgresConfig {
sslCa?: string;
maxConnections?: number;
kvTable?: string;
preparedStatements?: boolean;
}
export interface PostgresQueryable {
query<T extends QueryResultRow = QueryResultRow>(text: string, values?: Array<unknown>): Promise<QueryResult<T>>;
query<T extends QueryResultRow = QueryResultRow>(
text: string,
values?: Array<unknown>,
name?: string,
): Promise<QueryResult<T>>;
}
export interface IPostgresClient extends PostgresQueryable {
@@ -92,15 +97,20 @@ class PostgresClient implements IPostgresClient {
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
values: Array<unknown> = [],
name?: string,
): Promise<QueryResult<T>> {
return this.getPool().query<T>(text, values);
return this.getPool().query<T>({text, values, name: this.statementName(name)});
}
private statementName(name: string | undefined): string | undefined {
return this.config.preparedStatements === false ? undefined : name;
}
async transaction<T>(fn: (client: PostgresQueryable) => Promise<T>): Promise<T> {
const client = await this.getPool().connect();
try {
await client.query('BEGIN');
const result = await fn(client);
const result = await fn(poolClientQueryable(client, this.config.preparedStatements !== false));
await client.query('COMMIT');
return result;
} catch (error) {
@@ -123,6 +133,13 @@ class PostgresClient implements IPostgresClient {
}
}
function poolClientQueryable(client: PoolClient, preparedStatements: boolean): PostgresQueryable {
return {
query: <T extends QueryResultRow = QueryResultRow>(text: string, values: Array<unknown> = [], name?: string) =>
client.query<T>({text, values, name: preparedStatements ? name : undefined}),
};
}
async function rollback(client: PoolClient): Promise<void> {
try {
await client.query('ROLLBACK');
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {existsSync, readFileSync, rmSync, statSync} from 'node:fs';
import {isBuiltin} from 'node:module';
import {dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import {build} from 'esbuild';
const API_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REPO_ROOT = resolve(API_ROOT, '..');
const OUT_DIR = join(API_ROOT, 'dist');
const CANDIDATE_SUFFIXES = ['', '.ts', '.tsx', '.js', '.mjs', '/index.ts', '/index.tsx', '/index.js', '/src/index.ts'];
const WORKSPACE_ROOTS = {
'@app/': join(API_ROOT, 'src'),
'@pkgs/': join(API_ROOT, 'pkgs'),
'@fluxer/': join(REPO_ROOT, 'packages'),
};
function resolveWorkspacePath(specifier) {
for (const [prefix, root] of Object.entries(WORKSPACE_ROOTS)) {
if (!specifier.startsWith(prefix)) {
continue;
}
const base = join(root, specifier.slice(prefix.length));
for (const suffix of CANDIDATE_SUFFIXES) {
const candidate = `${base}${suffix}`;
if (existsSync(candidate) && statSync(candidate).isFile()) {
return candidate;
}
}
throw new Error(`Unable to resolve workspace import ${specifier}`);
}
return null;
}
function packageNameOf(specifier) {
const segments = specifier.split('/');
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
}
const declaredDependencies = new Set(
Object.keys(JSON.parse(readFileSync(join(API_ROOT, 'package.json'), 'utf-8')).dependencies),
);
const undeclaredDependencies = new Set();
const workspacePlugin = {
name: 'fluxer-workspace',
setup(pluginBuild) {
pluginBuild.onResolve({filter: /^[^./]/}, (args) => {
const workspacePath = resolveWorkspacePath(args.path);
if (workspacePath) {
return {path: workspacePath};
}
const packageName = packageNameOf(args.path);
if (!isBuiltin(args.path) && !declaredDependencies.has(packageName)) {
undeclaredDependencies.add(packageName);
}
return {path: args.path, external: true};
});
},
};
rmSync(OUT_DIR, {recursive: true, force: true});
await build({
entryPoints: [join(API_ROOT, 'src/AppEntrypoint.ts'), join(API_ROOT, 'src/WorkerEntrypoint.ts')],
outdir: OUT_DIR,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node24',
charset: 'utf8',
sourcemap: true,
sourcesContent: false,
logLevel: 'info',
banner: {js: '// SPDX-License-Identifier: AGPL-3.0-or-later'},
plugins: [workspacePlugin],
});
if (undeclaredDependencies.size > 0) {
const names = [...undeclaredDependencies].sort().join(', ');
throw new Error(
`The bundle imports packages that fluxer_api does not declare as dependencies: ${names}. ` +
'Workspace packages keep their own node_modules, so the bundle cannot reach them from fluxer_api.',
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAPIApp} from '@app/api/App';
import {initializeConfig} from '@app/api/Config';
import {buildAPIServerOptions, initializeConfig} from '@app/api/Config';
import {initializeLogger} from '@app/api/Logger';
import {Config} from '@app/Config';
import {shutdownInstrumentation} from '@app/Instrument';
@@ -34,7 +34,7 @@ async function main(): Promise<void> {
process.on('unhandledRejection', (reason) => {
Logger.error({reason}, 'Unhandled rejection (suppressed)');
});
const server = createServer(app, {port: Config.port});
const server = createServer(app, buildAPIServerOptions(Config));
Logger.info({port: Config.port}, `Starting Fluxer API on port ${Config.port}`);
setupGracefulShutdown(
async () => {
+1
View File
@@ -48,6 +48,7 @@ export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIApp
corsOrigins: [config.endpoints.webApp, config.endpoints.marketing],
trustClientIpHeader: config.proxy.trust_client_ip_header,
clientIpHeaderName: config.proxy.client_ip_header,
maxInflightRequests: config.maxInflightRequests,
});
routes.onError(AbuseAwareAppErrorHandler);
routes.notFound(AppNotFoundHandler);
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
import {createServer} from '@fluxer/hono/src/Server';
import {Hono} from 'hono';
import {afterAll, afterEach, describe, expect, test, vi} from 'vitest';
import {buildAPIConfigFromMaster, buildAPIServerOptions} from './Config';
interface ListeningServer {
close: (callback: () => void) => void;
headersTimeout: number;
requestTimeout: number;
}
const servers: Array<ListeningServer> = [];
async function listenWithEnv(env: Record<string, string> = {}): Promise<ListeningServer> {
for (const [key, value] of Object.entries({FLUXER_API_PORT: '0', ...env})) {
vi.stubEnv(key, value);
}
resetConfig();
const config = buildAPIConfigFromMaster(await loadConfig());
const server = createServer(new Hono(), buildAPIServerOptions(config)) as unknown as ListeningServer;
servers.push(server);
return server;
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
vi.unstubAllEnvs();
resetConfig();
});
afterAll(async () => {
await loadConfig();
});
describe('buildAPIServerOptions', () => {
test('starts the api on the shipped header and request timeouts', async () => {
const server = await listenWithEnv();
expect(server.headersTimeout).toBe(30_000);
expect(server.requestTimeout).toBe(120_000);
});
test('carries the operator header timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_HEADERS_TIMEOUT_MS: '45000'});
expect(server.headersTimeout).toBe(45_000);
expect(server.requestTimeout).toBe(120_000);
});
test('carries the operator request timeout from the environment into the server', async () => {
const server = await listenWithEnv({FLUXER_API_REQUEST_TIMEOUT_MS: '600000'});
expect(server.headersTimeout).toBe(30_000);
expect(server.requestTimeout).toBe(600_000);
});
test('clamps a header timeout set above the request timeout', async () => {
const server = await listenWithEnv({
FLUXER_API_HEADERS_TIMEOUT_MS: '90000',
FLUXER_API_REQUEST_TIMEOUT_MS: '45000',
});
expect(server.requestTimeout).toBe(45_000);
expect(server.headersTimeout).toBe(45_000);
});
});
+26
View File
@@ -65,6 +65,13 @@ function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
function resolveValidateResponses(master: MasterConfig): boolean {
if (isBoolean(master.dev.validate_responses)) {
return master.dev.validate_responses;
}
return master.env !== 'production';
}
function resolveTrustClientIpHeader(proxyConfig: object): boolean {
const configuredValue = Reflect.get(proxyConfig, 'trust_client_ip_header');
if (isBoolean(configuredValue)) {
@@ -153,6 +160,9 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
return {
nodeEnv: master.env === 'test' ? 'development' : master.env,
port: master.services.api.port,
headersTimeoutMs: master.services.api.headers_timeout_ms,
requestTimeoutMs: master.services.api.request_timeout_ms,
maxInflightRequests: master.services.api.max_inflight_requests,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
@@ -177,6 +187,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
sslCa: postgresSource?.ssl_ca ?? '',
maxConnections: postgresSource?.max_connections ?? 20,
kvTable: postgresSource?.kv_table ?? 'fluxer_kv',
preparedStatements: postgresSource?.prepared_statements ?? true,
},
database: {
backend: master.database.backend,
@@ -467,6 +478,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
disableRateLimits: master.dev.disable_rate_limits,
testModeEnabled: master.dev.test_mode_enabled,
testHarnessToken: master.dev.test_harness_token,
validateResponses: resolveValidateResponses(master),
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
@@ -520,6 +532,20 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
};
}
interface APIServerOptions {
port: number;
headersTimeoutMs: number;
requestTimeoutMs: number;
}
export function buildAPIServerOptions(config: APIConfig): APIServerOptions {
return {
port: config.port,
headersTimeoutMs: config.headersTimeoutMs,
requestTimeoutMs: config.requestTimeoutMs,
};
}
let _config: APIConfig | null = null;
export function initializeConfig(config: APIConfig): void {
-2
View File
@@ -10,7 +10,6 @@ import {IntegrationRateLimitConfigs} from './rate_limit_configs/IntegrationRateL
import {InviteRateLimitConfigs} from './rate_limit_configs/InviteRateLimitConfig';
import {MiscRateLimitConfigs} from './rate_limit_configs/MiscRateLimitConfig';
import {OAuthRateLimitConfigs} from './rate_limit_configs/OAuthRateLimitConfig';
import {PackRateLimitConfigs} from './rate_limit_configs/PackRateLimitConfig';
import type {RateLimitSection} from './rate_limit_configs/RateLimitHelpers';
import {mergeRateLimitSections} from './rate_limit_configs/RateLimitHelpers';
import {UserRateLimitConfigs} from './rate_limit_configs/UserRateLimitConfig';
@@ -29,6 +28,5 @@ const rateLimitSections = [
IntegrationRateLimitConfigs,
AdminRateLimitConfigs,
MiscRateLimitConfigs,
PackRateLimitConfigs,
] satisfies ReadonlyArray<RateLimitSection>;
export const RateLimitConfigs = mergeRateLimitSections(...rateLimitSections);
-29
View File
@@ -290,13 +290,10 @@ import {
type SuspiciousIpRow,
} from './database/types/RiskTypes';
import {
EXPRESSION_PACK_COLUMNS,
type ExpressionPackRow,
FAVORITE_MEME_COLUMNS,
type FavoriteMemeRow,
NOTE_COLUMNS,
type NoteRow,
type PackInstallationRow,
PUSH_SUBSCRIPTION_COLUMNS,
type PushSubscriptionRow,
RECENT_MENTION_COLUMNS,
@@ -305,8 +302,6 @@ import {
type RelationshipRow,
SAVED_MESSAGE_COLUMNS,
type SavedMessageRow,
SCHEDULED_MESSAGE_COLUMNS,
type ScheduledMessageRow,
USER_BY_EMAIL_COLUMNS,
USER_BY_LAST_ACTIVE_IP_COLUMNS,
USER_BY_LAST_ACTIVE_IP_TRUST_KEY_COLUMNS,
@@ -684,11 +679,6 @@ export const SavedMessages = defineTable<SavedMessageRow, 'user_id' | 'message_i
columns: SAVED_MESSAGE_COLUMNS,
primaryKey: ['user_id', 'message_id'],
});
export const ScheduledMessages = defineTable<ScheduledMessageRow, 'user_id' | 'scheduled_message_id'>({
name: 'scheduled_messages',
columns: SCHEDULED_MESSAGE_COLUMNS,
primaryKey: ['user_id', 'scheduled_message_id'],
});
export const PushSubscriptions = defineTable<PushSubscriptionRow, 'user_id' | 'subscription_id'>({
name: 'push_subscriptions',
columns: PUSH_SUBSCRIPTION_COLUMNS,
@@ -1011,25 +1001,6 @@ export const FavoriteMemesByMemeId = defineTable<FavoriteMemesByMemeIdRow, 'meme
columns: FAVORITE_MEMES_BY_MEME_ID_COLUMNS,
primaryKey: ['meme_id', 'user_id'],
});
export const ExpressionPacks = defineTable<ExpressionPackRow, 'pack_id'>({
name: 'expression_packs',
columns: EXPRESSION_PACK_COLUMNS,
primaryKey: ['pack_id'],
});
export const ExpressionPacksByCreator = defineTable<ExpressionPackRow, 'creator_id' | 'pack_id'>({
name: 'expression_packs_by_creator',
columns: EXPRESSION_PACK_COLUMNS,
primaryKey: ['creator_id', 'pack_id'],
partitionKey: ['creator_id'],
});
const PACK_INSTALLATION_COLUMNS = ['user_id', 'pack_id', 'pack_type', 'installed_at'] as const satisfies ReadonlyArray<
keyof PackInstallationRow
>;
export const PackInstallations = defineTable<PackInstallationRow, 'user_id' | 'pack_id'>({
name: 'pack_installations',
columns: PACK_INSTALLATION_COLUMNS,
primaryKey: ['user_id', 'pack_id'],
});
interface InvitesByChannelRow {
channel_id: ChannelID;
+2 -7
View File
@@ -10,7 +10,7 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
import {getCookie} from 'hono/cookie';
import type {ZodError, ZodTypeAny} from 'zod';
import {parseJsonPreservingLargeIntegers} from './utils/LosslessJsonParser';
import {readRequestJsonBody} from './utils/RequestJsonBody';
import {initializeFluxerErrorMap} from './ZodErrorMap';
initializeFluxerErrorMap();
@@ -200,12 +200,7 @@ export const Validator = <
let value: unknown;
switch (target) {
case 'json':
try {
const raw = await c.req.text();
value = raw.trim().length === 0 ? {} : parseJsonPreservingLargeIntegers(raw);
} catch {
value = {};
}
value = (await readRequestJsonBody(c.req)).value;
break;
case 'form': {
const formData = await c.req.formData();
-2
View File
@@ -66,8 +66,6 @@ function fluxerZodErrorMap(issue: FluxerZodErrorMapIssue): FluxerZodErrorMapResu
const origin = 'origin' in issue ? String(issue.origin) : undefined;
if (origin === 'string') {
errorCode = ValidationErrorCodes.CONTENT_EXCEEDS_MAX_LENGTH;
} else if (origin === 'date') {
errorCode = ValidationErrorCodes.SCHEDULED_TIME_MUST_BE_FUTURE;
} else {
errorCode = ValidationErrorCodes.INVALID_FORMAT;
}
File diff suppressed because it is too large Load Diff
@@ -626,14 +626,14 @@ async function updatePendingRegistrationUser(
return;
}
const traits = new Set(user.traits);
traits.delete(REGISTRATION_PENDING_APPROVAL_TRAIT);
const wasPendingApproval = traits.delete(REGISTRATION_PENDING_APPROVAL_TRAIT);
if (decision === 'reject') {
traits.add(REGISTRATION_REJECTED_TRAIT);
} else {
traits.delete(REGISTRATION_REJECTED_TRAIT);
}
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
if (decision === 'approve') {
if (decision === 'approve' && wasPendingApproval) {
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
}
await ctx.get('adminService').auditService.createAuditLog({
@@ -1,93 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Readable} from 'node:stream';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {
VoiceDiagnosticsObjectListResponse,
VoiceDiagnosticsQueryRequest,
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
import {VOICE_DIAGNOSTICS_BUCKET, VoiceDiagnosticsService} from '../../channel/services/VoiceDiagnosticsService';
import {requireAdminACL} from '../../middleware/AdminMiddleware';
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '../../RateLimitConfig';
import type {HonoApp} from '../../types/HonoEnv';
import {Validator} from '../../Validator';
export function VoiceDiagnosticsAdminController(app: HonoApp) {
app.get(
'/admin/voice/diagnostics/objects',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_DIAGNOSTICS_VIEW),
Validator('query', VoiceDiagnosticsQueryRequest),
OpenAPI({
operationId: 'list_voice_diagnostics_objects',
summary: 'List voice diagnostics objects',
responseSchema: VoiceDiagnosticsObjectListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Lists raw voice diagnostics NDJSON S3 objects for a channel and time range. Requires VOICE_DIAGNOSTICS_VIEW permission.',
}),
async (ctx) => {
const query = ctx.req.valid('query');
const service = new VoiceDiagnosticsService(
ctx.get('cacheService'),
ctx.get('channelService'),
ctx.get('gatewayService'),
ctx.get('storageService'),
);
const objects = await service.listObjects({
channelId: query.channel_id,
startMs: query.start_ms,
endMs: query.end_ms,
sessionId: query.session_id,
limitObjects: query.limit_objects,
});
return ctx.json({bucket: VOICE_DIAGNOSTICS_BUCKET, objects});
},
);
app.get(
'/admin/voice/diagnostics/raw',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.VOICE_DIAGNOSTICS_VIEW),
Validator('query', VoiceDiagnosticsQueryRequest),
OpenAPI({
operationId: 'stream_voice_diagnostics_raw',
summary: 'Stream raw voice diagnostics',
responseSchema: null,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Streams matching voice diagnostics NDJSON objects for local processing. Requires VOICE_DIAGNOSTICS_VIEW permission.',
}),
async (ctx) => {
const query = ctx.req.valid('query');
const service = new VoiceDiagnosticsService(
ctx.get('cacheService'),
ctx.get('channelService'),
ctx.get('gatewayService'),
ctx.get('storageService'),
);
const objects = await service.listObjects({
channelId: query.channel_id,
startMs: query.start_ms,
endMs: query.end_ms,
sessionId: query.session_id,
limitObjects: query.limit_objects,
});
const stream = service.createRawObjectStream(objects);
return new Response(Readable.toWeb(stream) as ReadableStream, {
status: 200,
headers: {
'Content-Type': 'application/x-ndjson',
'Cache-Control': 'no-store, private',
'X-Fluxer-Diagnostics-Bucket': VOICE_DIAGNOSTICS_BUCKET,
'X-Fluxer-Diagnostics-Object-Count': objects.length.toString(),
},
});
},
);
}
@@ -7,7 +7,6 @@ import {ArchiveAdminController} from './ArchiveAdminController';
import {AssetAdminController} from './AssetAdminController';
import {AuditLogAdminController} from './AuditLogAdminController';
import {BanAdminController} from './BanAdminController';
import {BillingAdminController} from './BillingAdminController';
import {BulkAdminController} from './BulkAdminController';
import {CodesAdminController} from './CodesAdminController';
import {DiscoveryAdminController} from './DiscoveryAdminController';
@@ -23,7 +22,6 @@ import {SystemAdminController} from './SystemAdminController';
import {SystemDmAdminController} from './SystemDmAdminController';
import {UserAdminController} from './UserAdminController';
import {VoiceAdminController} from './VoiceAdminController';
import {VoiceDiagnosticsAdminController} from './VoiceDiagnosticsAdminController';
export function registerAdminControllers(app: HonoApp) {
AdminApiKeyAdminController(app);
@@ -40,9 +38,7 @@ export function registerAdminControllers(app: HonoApp) {
AuditLogAdminController(app);
ArchiveAdminController(app);
ReportAdminController(app);
BillingAdminController(app);
VoiceAdminController(app);
VoiceDiagnosticsAdminController(app);
GatewayAdminController(app);
SearchAdminController(app);
DiscoveryAdminController(app);
@@ -133,6 +133,7 @@ export class AdminGuildMembershipService {
guildId,
targetId,
deleteMessageDays: data.delete_message_days,
deleteMessageSeconds: data.delete_message_seconds,
reason: data.reason ?? undefined,
banDurationSeconds: data.ban_duration_seconds ?? undefined,
skipGuildAuditLog: true,
@@ -1,62 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
describe('Admin Billing Authorization', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('billing overview requires billing:view ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
await createBuilder(harness, `${admin.token}`)
.get(`/admin/billing/users/${admin.userId}/overview`)
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('billing refund requires billing:refund ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${admin.userId}/refund`)
.body({payment_intent_id: 'pi_test_refund'})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('billing subscription management requires billing:manage_subscription ACL', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate']);
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${admin.userId}/cancel-subscription`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${admin.userId}/reactivate-subscription`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
test('refund policy immediate cancellation requires both refund and subscription management ACLs', async () => {
const refundOnlyAdmin = await createTestAccount(harness);
await setUserACLs(harness, refundOnlyAdmin, ['admin:authenticate', 'billing:refund']);
await createBuilder(harness, `${refundOnlyAdmin.token}`)
.post(`/admin/billing/users/${refundOnlyAdmin.userId}/refund-policy-cancel-now`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
const subscriptionOnlyAdmin = await createTestAccount(harness);
await setUserACLs(harness, subscriptionOnlyAdmin, ['admin:authenticate', 'billing:manage_subscription']);
await createBuilder(harness, `${subscriptionOnlyAdmin.token}`)
.post(`/admin/billing/users/${subscriptionOnlyAdmin.userId}/refund-policy-cancel-now`)
.body({})
.expect(HTTP_STATUS.FORBIDDEN)
.execute();
});
});
@@ -1,924 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import type {
AdminBillingOverviewResponse,
AdminBillingRefundLatestInvoiceCancelResponse,
AdminInvoiceListResponse,
} from '@fluxer/schema/src/domains/admin/AdminBillingSchemas';
import type Stripe from 'stripe';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import {createUserID} from '../../BrandedTypes';
import {getBillingRepository} from '../../middleware/ServiceRegistry';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
import {server} from '../../test/msw/server';
import {createBuilder} from '../../test/TestRequestBuilder';
import {PaymentRepository} from '../../user/repositories/PaymentRepository';
const DAY_SECONDS = 24 * 60 * 60;
function stripeFixture<T>(value: object): T {
return value as T;
}
describe('Admin billing overview', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
afterAll(async () => {
await harness.shutdown();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
server.resetHandlers();
});
async function setStripeCustomerId(userId: string, stripeCustomerId: string): Promise<void> {
await createBuilder(harness, '')
.post(`/test/users/${userId}/premium`)
.body({stripe_customer_id: stripeCustomerId})
.execute();
}
async function mirrorCustomer(params: {stripeCustomerId: string; userId?: string}): Promise<void> {
await getBillingRepository().customers.upsertFromStripe(
{
id: params.stripeCustomerId,
object: 'customer',
created: Math.floor(Date.now() / 1000),
email: null,
invoice_settings: {default_payment_method: null},
livemode: false,
metadata: params.userId ? {userId: params.userId} : {},
} as Stripe.Customer,
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
);
}
async function mirrorSubscription(params: {
currentPeriodEnd?: number;
currentPeriodStart?: number;
latestInvoiceId?: string;
status?: Stripe.Subscription.Status;
stripeCustomerId: string;
stripeSubscriptionId: string;
userId?: string;
}): Promise<void> {
const now = Math.floor(Date.now() / 1000);
const currentPeriodStart = params.currentPeriodStart ?? now - DAY_SECONDS;
const currentPeriodEnd = params.currentPeriodEnd ?? now + 29 * DAY_SECONDS;
await getBillingRepository().subscriptions.upsertFromStripe(
{
id: params.stripeSubscriptionId,
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
collection_method: 'charge_automatically',
created: currentPeriodStart,
currency: 'eur',
customer: params.stripeCustomerId,
items: {
data: [
{
id: `si_${params.stripeSubscriptionId}`,
current_period_start: currentPeriodStart,
current_period_end: currentPeriodEnd,
price: {
id: 'price_monthly_eur',
product: 'prod_monthly',
unit_amount: 499,
},
quantity: 1,
},
],
},
latest_invoice: params.latestInvoiceId ?? null,
livemode: false,
metadata: params.userId ? {userId: params.userId} : {},
status: params.status ?? 'active',
},
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
);
}
async function mirrorInvoice(params: {
amountPaidCents: number;
chargeId?: string;
created?: number;
currency?: string;
invoiceId: string;
paymentIntentId?: string;
paymentId?: string;
stripeCustomerId: string;
stripeSubscriptionId?: string;
userId?: string;
}): Promise<void> {
const created = params.created ?? Math.floor(Date.now() / 1000);
await getBillingRepository().invoices.upsertFromStripe(
stripeFixture<Stripe.Invoice>({
id: params.invoiceId,
object: 'invoice',
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
amount_remaining: 0,
attempt_count: 1,
attempted: true,
billing_reason: 'subscription_cycle',
collection_method: 'charge_automatically',
created,
currency: params.currency ?? 'eur',
customer: params.stripeCustomerId,
livemode: false,
metadata: params.userId ? {userId: params.userId} : {},
paid: true,
payments:
params.paymentIntentId || params.chargeId
? {
object: 'list',
data: [
{
id: params.paymentId ?? `inpay_${params.invoiceId}`,
object: 'invoice_payment',
amount_paid: params.amountPaidCents,
amount_requested: params.amountPaidCents,
created,
currency: params.currency ?? 'eur',
invoice: params.invoiceId,
is_default: true,
livemode: false,
payment: {
type: 'payment_intent',
payment_intent: params.paymentIntentId ?? null,
charge: params.chargeId ?? null,
},
status: 'paid',
status_transitions: {canceled_at: null, paid_at: created + 20},
},
],
has_more: false,
url: `/v1/invoices/${params.invoiceId}/payments`,
}
: {object: 'list', data: [], has_more: false, url: `/v1/invoices/${params.invoiceId}/payments`},
status: 'paid',
status_transitions: {finalized_at: created, paid_at: created + 20, voided_at: null},
subscription: params.stripeSubscriptionId ?? null,
subtotal: params.amountPaidCents,
total: params.amountPaidCents,
}),
params.userId ? {knownUserId: BigInt(params.userId)} : undefined,
);
}
async function mirrorPaymentIntent(params: {
amountCents?: number;
chargeId?: string;
invoiceId?: string;
paymentIntentId: string;
stripeCustomerId: string;
}): Promise<void> {
await getBillingRepository().paymentIntents.upsertFromStripe(
stripeFixture<Stripe.PaymentIntent>({
id: params.paymentIntentId,
object: 'payment_intent',
amount: params.amountCents ?? 499,
amount_capturable: 0,
amount_received: params.amountCents ?? 499,
capture_method: 'automatic',
confirmation_method: 'automatic',
created: Math.floor(Date.now() / 1000),
currency: 'eur',
customer: params.stripeCustomerId,
invoice: params.invoiceId ?? null,
latest_charge: params.chargeId ?? null,
livemode: false,
metadata: {},
payment_method_types: ['card'],
status: 'succeeded',
}),
);
}
async function mirrorCharge(params: {
amountCents?: number;
chargeId: string;
invoiceId?: string;
paymentIntentId?: string;
stripeCustomerId: string;
}): Promise<void> {
await getBillingRepository().charges.upsertFromStripe(
stripeFixture<Stripe.Charge>({
id: params.chargeId,
object: 'charge',
amount: params.amountCents ?? 499,
amount_captured: params.amountCents ?? 499,
amount_refunded: 0,
billing_details: {address: {country: null}},
captured: true,
created: Math.floor(Date.now() / 1000),
currency: 'eur',
customer: params.stripeCustomerId,
invoice: params.invoiceId ?? null,
livemode: false,
metadata: {},
paid: true,
payment_intent: params.paymentIntentId ?? null,
payment_method_details: {type: 'card', card: {brand: 'visa', last4: '4242', country: null}},
refunded: false,
status: 'succeeded',
}),
);
}
async function mirrorPaymentMethod(params: {paymentMethodId: string; stripeCustomerId: string}): Promise<void> {
await getBillingRepository().paymentMethods.upsertFromStripe(
{
id: params.paymentMethodId,
object: 'payment_method',
billing_details: {address: {country: 'US'}, email: null, name: null, phone: null},
card: {brand: 'visa', country: 'US', exp_month: 12, exp_year: 2031, funding: 'credit', last4: '4242'},
created: Math.floor(Date.now() / 1000),
customer: params.stripeCustomerId,
livemode: false,
metadata: {},
type: 'card',
} as Stripe.PaymentMethod,
{isDefault: true},
);
}
async function setStripeSubscriptionState(params: {
userId: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
}): Promise<void> {
await createBuilder(harness, '')
.post(`/test/users/${params.userId}/premium`)
.body({
stripe_customer_id: params.stripeCustomerId,
stripe_subscription_id: params.stripeSubscriptionId,
premium_type: UserPremiumTypes.SUBSCRIPTION,
premium_billing_cycle: 'monthly',
premium_will_cancel: false,
})
.execute();
}
function createRefundPolicyStripeHandlers(params: {
amountPaidCents: number;
currency?: string;
elapsedDays: number;
invoiceId: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
}) {
const now = Math.floor(Date.now() / 1000);
const currentPeriodStart = now - params.elapsedDays * DAY_SECONDS;
const currentPeriodEnd = currentPeriodStart + 30 * DAY_SECONDS;
return createStripeApiHandlers({
subscriptions: {
[params.stripeSubscriptionId]: {
customer: params.stripeCustomerId,
current_period_start: currentPeriodStart,
current_period_end: currentPeriodEnd,
latest_invoice: params.invoiceId,
status: 'active',
},
},
invoices: {
[params.invoiceId]: {
customer: params.stripeCustomerId,
subscriptionId: params.stripeSubscriptionId,
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
billing_reason: 'subscription_cycle',
currency: params.currency ?? 'eur',
created: now - 300,
status: 'paid',
},
},
});
}
async function createPaymentRecord(params: {
userId: string;
checkoutSessionId: string;
completedAt?: Date;
euWithdrawalWaiverAccepted?: boolean;
euWithdrawalWaiverAcceptedAt?: Date | null;
euWithdrawalWaiverRequired?: boolean;
euWithdrawalWaiverTextVersion?: string | null;
invoiceId: string;
purchaseClientCountryCode?: string | null;
purchaseGeoipCountryCode?: string | null;
subscriptionId: string;
stripeCustomerId: string;
}): Promise<void> {
const paymentRepository = new PaymentRepository();
const createdAt = params.completedAt ?? new Date('2026-02-23T14:27:32.409Z');
await paymentRepository.createPayment({
checkout_session_id: params.checkoutSessionId,
user_id: createUserID(BigInt(params.userId)),
price_id: 'price_monthly_eur',
product_type: 'monthly_subscription',
status: 'completed',
is_gift: false,
created_at: createdAt,
purchase_geoip_country_code: params.purchaseGeoipCountryCode ?? null,
purchase_client_country_code: params.purchaseClientCountryCode ?? null,
eu_withdrawal_waiver_required: params.euWithdrawalWaiverRequired ?? false,
eu_withdrawal_waiver_accepted: params.euWithdrawalWaiverAccepted ?? false,
eu_withdrawal_waiver_accepted_at: params.euWithdrawalWaiverAcceptedAt ?? null,
eu_withdrawal_waiver_text_version: params.euWithdrawalWaiverTextVersion ?? null,
});
await paymentRepository.updatePayment({
checkout_session_id: params.checkoutSessionId,
stripe_customer_id: params.stripeCustomerId,
payment_intent_id: null,
subscription_id: params.subscriptionId,
invoice_id: params.invoiceId,
amount_cents: 499,
currency: 'eur',
status: 'completed',
completed_at: createdAt,
purchase_geoip_country_code: params.purchaseGeoipCountryCode ?? null,
purchase_client_country_code: params.purchaseClientCountryCode ?? null,
eu_withdrawal_waiver_required: params.euWithdrawalWaiverRequired ?? false,
eu_withdrawal_waiver_accepted: params.euWithdrawalWaiverAccepted ?? false,
eu_withdrawal_waiver_accepted_at: params.euWithdrawalWaiverAcceptedAt ?? null,
eu_withdrawal_waiver_text_version: params.euWithdrawalWaiverTextVersion ?? null,
});
}
test('resolves missing payment intents from Stripe invoice payments for overview and invoice listings', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'billing:view']);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_billing_target';
await setStripeCustomerId(targetUser.userId, stripeCustomerId);
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_billing_overview_1',
invoiceId: 'in_local_checkout_1',
subscriptionId: 'sub_billing_target',
stripeCustomerId,
});
const stripeHandlers = createStripeApiHandlers({
invoices: {
in_local_checkout_1: {
customer: stripeCustomerId,
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
currency: 'eur',
created: 1771862851,
payments: {
object: 'list',
data: [
{
id: 'inpay_local_checkout_1',
object: 'invoice_payment',
amount_paid: 499,
amount_requested: 499,
created: 1771862851,
currency: 'eur',
invoice: 'in_local_checkout_1',
is_default: true,
livemode: false,
payment: {
type: 'payment_intent',
payment_intent: 'pi_local_checkout_1',
charge: 'ch_local_checkout_1',
},
status: 'paid',
status_transitions: {
canceled_at: null,
paid_at: 1771862871,
},
},
],
has_more: false,
url: '/v1/invoices/in_local_checkout_1/payments',
},
},
in_renewal_1: {
customer: stripeCustomerId,
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_cycle',
currency: 'eur',
created: 1776065330,
payments: {
object: 'list',
data: [
{
id: 'inpay_renewal_1',
object: 'invoice_payment',
amount_paid: 499,
amount_requested: 499,
created: 1776065330,
currency: 'eur',
invoice: 'in_renewal_1',
is_default: true,
livemode: false,
payment: {
type: 'payment_intent',
payment_intent: 'pi_renewal_1',
charge: 'ch_renewal_1',
},
status: 'paid',
status_transitions: {
canceled_at: null,
paid_at: 1776065360,
},
},
],
has_more: false,
url: '/v1/invoices/in_renewal_1/payments',
},
},
},
paymentIntents: {
pi_local_checkout_1: {
customer: stripeCustomerId,
currency: 'eur',
latest_charge: 'ch_local_checkout_1',
},
pi_renewal_1: {
customer: stripeCustomerId,
currency: 'eur',
latest_charge: 'ch_renewal_1',
},
},
paymentMethods: {
pm_billing_target_1: {
customer: stripeCustomerId,
type: 'card',
card: {
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2031,
country: 'US',
},
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_local_checkout_1',
created: 1771862851,
invoiceId: 'in_local_checkout_1',
paymentId: 'inpay_local_checkout_1',
paymentIntentId: 'pi_local_checkout_1',
stripeCustomerId,
stripeSubscriptionId: 'sub_billing_target',
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_renewal_1',
created: 1776065330,
invoiceId: 'in_renewal_1',
paymentId: 'inpay_renewal_1',
paymentIntentId: 'pi_renewal_1',
stripeCustomerId,
stripeSubscriptionId: 'sub_billing_target',
userId: targetUser.userId,
});
await mirrorPaymentIntent({
chargeId: 'ch_local_checkout_1',
invoiceId: 'in_local_checkout_1',
paymentIntentId: 'pi_local_checkout_1',
stripeCustomerId,
});
await mirrorPaymentIntent({
chargeId: 'ch_renewal_1',
invoiceId: 'in_renewal_1',
paymentIntentId: 'pi_renewal_1',
stripeCustomerId,
});
await mirrorCharge({
chargeId: 'ch_local_checkout_1',
invoiceId: 'in_local_checkout_1',
paymentIntentId: 'pi_local_checkout_1',
stripeCustomerId,
});
await mirrorCharge({
chargeId: 'ch_renewal_1',
invoiceId: 'in_renewal_1',
paymentIntentId: 'pi_renewal_1',
stripeCustomerId,
});
await mirrorPaymentMethod({paymentMethodId: 'pm_billing_target_1', stripeCustomerId});
const overview = await createBuilder<AdminBillingOverviewResponse>(harness, `${admin.token}`)
.get(`/admin/billing/users/${targetUser.userId}/overview`)
.execute();
expect(overview.payments).toHaveLength(2);
const localCheckoutPayment = overview.payments.find((payment) => payment.invoice_id === 'in_local_checkout_1');
expect(localCheckoutPayment?.payment_intent_id).toBe('pi_local_checkout_1');
expect(localCheckoutPayment?.resolved_payment_intent_id).toBe('pi_local_checkout_1');
expect(localCheckoutPayment?.charge_id).toBe('ch_local_checkout_1');
expect(localCheckoutPayment?.refundable_via_payment_intent).toBe(true);
expect(overview.payment_methods[0]?.id).toBe('pm_billing_target_1');
const invoices = await createBuilder<AdminInvoiceListResponse>(harness, `${admin.token}`)
.get(`/admin/billing/users/${targetUser.userId}/invoices`)
.execute();
expect(invoices.invoices).toHaveLength(2);
expect(invoices.invoices[0]?.id).toBe('in_renewal_1');
expect(invoices.invoices[0]?.payment_intent_id).toBe('pi_renewal_1');
expect(invoices.invoices[0]?.charge_id).toBe('ch_renewal_1');
expect(invoices.invoices[0]?.billing_reason).toBe('subscription_cycle');
expect(invoices.invoices[1]?.payment_intent_id).toBe('pi_local_checkout_1');
});
test('resolves billing overview from Stripe metadata even when local Stripe linkage is missing', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), ['admin:authenticate', 'billing:view']);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_billing_metadata_only';
const stripeSubscriptionId = 'sub_billing_metadata_only';
const invoiceId = 'in_billing_metadata_only';
const now = Math.floor(Date.now() / 1000);
const stripeHandlers = createStripeApiHandlers({
customers: {
[stripeCustomerId]: {
email: '[email protected]',
metadata: {
userId: targetUser.userId,
},
},
},
invoices: {
[invoiceId]: {
customer: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
currency: 'eur',
created: now - 300,
status: 'paid',
},
},
paymentMethods: {
pm_billing_metadata_only: {
customer: stripeCustomerId,
type: 'card',
card: {
brand: 'visa',
last4: '1111',
exp_month: 8,
exp_year: 2031,
country: 'FR',
},
},
},
subscriptions: {
[stripeSubscriptionId]: {
customer: stripeCustomerId,
latest_invoice: invoiceId,
status: 'active',
current_period_start: now - DAY_SECONDS,
current_period_end: now + 29 * DAY_SECONDS,
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorCustomer({stripeCustomerId, userId: targetUser.userId});
await mirrorSubscription({
currentPeriodEnd: now + 29 * DAY_SECONDS,
currentPeriodStart: now - DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: `ch_${invoiceId}`,
created: now - 300,
invoiceId,
paymentIntentId: `pi_${invoiceId}`,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorPaymentIntent({
chargeId: `ch_${invoiceId}`,
invoiceId,
paymentIntentId: `pi_${invoiceId}`,
stripeCustomerId,
});
await mirrorCharge({
chargeId: `ch_${invoiceId}`,
invoiceId,
paymentIntentId: `pi_${invoiceId}`,
stripeCustomerId,
});
await mirrorPaymentMethod({paymentMethodId: 'pm_billing_metadata_only', stripeCustomerId});
const overview = await createBuilder<AdminBillingOverviewResponse>(harness, `${admin.token}`)
.get(`/admin/billing/users/${targetUser.userId}/overview`)
.execute();
expect(overview.stripe_customer_id).toBe(stripeCustomerId);
expect(overview.subscription?.id).toBe(stripeSubscriptionId);
expect(overview.subscription?.status).toBe('active');
expect(overview.payment_methods[0]?.id).toBe('pm_billing_metadata_only');
expect(overview.payments[0]?.invoice_id).toBe(invoiceId);
expect(overview.payments[0]?.resolved_payment_intent_id).toBe(`pi_${invoiceId}`);
});
test('cancels a Stripe subscription at period end after resolving missing local Stripe IDs from Stripe metadata', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_billing_cancel_metadata';
const stripeSubscriptionId = 'sub_billing_cancel_metadata';
const now = Math.floor(Date.now() / 1000);
const stripeHandlers = createStripeApiHandlers({
customers: {
[stripeCustomerId]: {
metadata: {
userId: targetUser.userId,
},
},
},
subscriptions: {
[stripeSubscriptionId]: {
customer: stripeCustomerId,
status: 'active',
current_period_start: now - DAY_SECONDS,
current_period_end: now + 29 * DAY_SECONDS,
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorCustomer({stripeCustomerId, userId: targetUser.userId});
await mirrorSubscription({
currentPeriodEnd: now + 29 * DAY_SECONDS,
currentPeriodStart: now - DAY_SECONDS,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/cancel-subscription`)
.body({})
.expect(204)
.execute();
expect(stripeHandlers.spies.updatedSubscriptions).toContainEqual({
id: stripeSubscriptionId,
params: {
cancel_at_period_end: 'true',
},
});
});
test('allows admin refunds when the payment intent belongs to the target Stripe customer even without a local payment-intent index', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_refund_target';
await setStripeCustomerId(targetUser.userId, stripeCustomerId);
const stripeHandlers = createStripeApiHandlers({
paymentIntents: {
pi_remote_only_refund: {
customer: stripeCustomerId,
latest_charge: 'ch_remote_only_refund',
},
},
});
server.use(...stripeHandlers.handlers);
await mirrorPaymentIntent({
chargeId: 'ch_remote_only_refund',
paymentIntentId: 'pi_remote_only_refund',
stripeCustomerId,
});
await createBuilder(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund`)
.body({
payment_intent_id: 'pi_remote_only_refund',
reason: 'Customer requested a refund',
})
.expect(204)
.execute();
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
payment_intent: 'pi_remote_only_refund',
reason: 'requested_by_customer',
metadata: {
admin_user_id: admin.userId,
target_user_id: targetUser.userId,
admin_reason: 'Customer requested a refund',
},
});
});
test('forces a full refund when the latest invoice is inside the EU withdrawal window without a waiver', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_eu_missing_waiver';
const stripeSubscriptionId = 'sub_eu_missing_waiver';
const invoiceId = 'in_eu_missing_waiver';
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_eu_missing_waiver',
completedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
euWithdrawalWaiverRequired: true,
euWithdrawalWaiverAccepted: false,
euWithdrawalWaiverTextVersion: '2026-04-23',
invoiceId,
purchaseClientCountryCode: 'DE',
purchaseGeoipCountryCode: 'DE',
subscriptionId: stripeSubscriptionId,
stripeCustomerId,
});
const stripeHandlers = createRefundPolicyStripeHandlers({
amountPaidCents: 499,
elapsedDays: 6,
invoiceId,
stripeCustomerId,
stripeSubscriptionId,
});
server.use(...stripeHandlers.handlers);
const now = Math.floor(Date.now() / 1000);
await mirrorSubscription({
currentPeriodEnd: now + 24 * DAY_SECONDS,
currentPeriodStart: now - 6 * DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_eu_missing_waiver',
invoiceId,
paymentIntentId: 'pi_eu_missing_waiver',
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorCharge({
chargeId: 'ch_eu_missing_waiver',
invoiceId,
paymentIntentId: 'pi_eu_missing_waiver',
stripeCustomerId,
});
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
.body({reason: 'Withdrawal waiver missing'})
.execute();
expect(result.refund_policy).toBe('full_refund');
expect(result.refund_policy_basis).toBe('eu_eea_withdrawal_no_waiver');
expect(result.refunded_amount_cents).toBe(499);
expect(result.eu_withdrawal_waiver_required).toBe(true);
expect(result.eu_withdrawal_waiver_accepted).toBe(false);
expect(result.purchase_geoip_country_code).toBe('DE');
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
amount: '499',
metadata: {
refund_policy: 'full_refund',
refund_policy_basis: 'eu_eea_withdrawal_no_waiver',
eu_withdrawal_waiver_required: 'true',
eu_withdrawal_waiver_accepted: 'false',
},
});
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
});
test('uses the support prorate policy when an EU waiver was accepted', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_eu_accepted_waiver';
const stripeSubscriptionId = 'sub_eu_accepted_waiver';
const invoiceId = 'in_eu_accepted_waiver';
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_eu_accepted_waiver',
completedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
euWithdrawalWaiverRequired: true,
euWithdrawalWaiverAccepted: true,
euWithdrawalWaiverAcceptedAt: new Date(Date.now() - 6 * DAY_SECONDS * 1000),
euWithdrawalWaiverTextVersion: '2026-04-23',
invoiceId,
purchaseClientCountryCode: 'DE',
purchaseGeoipCountryCode: 'DE',
subscriptionId: stripeSubscriptionId,
stripeCustomerId,
});
const stripeHandlers = createRefundPolicyStripeHandlers({
amountPaidCents: 499,
elapsedDays: 6,
invoiceId,
stripeCustomerId,
stripeSubscriptionId,
});
server.use(...stripeHandlers.handlers);
const now = Math.floor(Date.now() / 1000);
await mirrorSubscription({
currentPeriodEnd: now + 24 * DAY_SECONDS,
currentPeriodStart: now - 6 * DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_eu_accepted_waiver',
invoiceId,
paymentIntentId: 'pi_eu_accepted_waiver',
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorCharge({
chargeId: 'ch_eu_accepted_waiver',
invoiceId,
paymentIntentId: 'pi_eu_accepted_waiver',
stripeCustomerId,
});
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
.body({})
.execute();
expect(result.refund_policy).toBe('prorated_refund');
expect(result.refund_policy_basis).toBe('support_policy');
expect(result.refunded_amount_cents).toBe(400);
expect(stripeHandlers.spies.createdRefunds[0]).toMatchObject({
amount: '400',
metadata: {
refund_policy: 'prorated_refund',
refund_policy_basis: 'support_policy',
eu_withdrawal_waiver_required: 'true',
eu_withdrawal_waiver_accepted: 'true',
},
});
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
});
test('cancels without refund after the support refund window', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
'billing:refund',
'billing:manage_subscription',
]);
const targetUser = await createTestAccount(harness);
const stripeCustomerId = 'cus_cancel_only';
const stripeSubscriptionId = 'sub_cancel_only';
const invoiceId = 'in_cancel_only';
await setStripeSubscriptionState({userId: targetUser.userId, stripeCustomerId, stripeSubscriptionId});
await createPaymentRecord({
userId: targetUser.userId,
checkoutSessionId: 'cs_cancel_only',
completedAt: new Date(Date.now() - 20 * DAY_SECONDS * 1000),
invoiceId,
subscriptionId: stripeSubscriptionId,
stripeCustomerId,
});
const stripeHandlers = createRefundPolicyStripeHandlers({
amountPaidCents: 499,
elapsedDays: 20,
invoiceId,
stripeCustomerId,
stripeSubscriptionId,
});
server.use(...stripeHandlers.handlers);
const now = Math.floor(Date.now() / 1000);
await mirrorSubscription({
currentPeriodEnd: now + 10 * DAY_SECONDS,
currentPeriodStart: now - 20 * DAY_SECONDS,
latestInvoiceId: invoiceId,
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorInvoice({
amountPaidCents: 499,
chargeId: 'ch_cancel_only',
invoiceId,
paymentIntentId: 'pi_cancel_only',
stripeCustomerId,
stripeSubscriptionId,
userId: targetUser.userId,
});
await mirrorCharge({
chargeId: 'ch_cancel_only',
invoiceId,
paymentIntentId: 'pi_cancel_only',
stripeCustomerId,
});
const result = await createBuilder<AdminBillingRefundLatestInvoiceCancelResponse>(harness, `${admin.token}`)
.post(`/admin/billing/users/${targetUser.userId}/refund-policy-cancel-now`)
.body({})
.execute();
expect(result.refund_policy).toBe('cancel_only');
expect(result.refund_policy_basis).toBe('support_policy');
expect(result.refunded_amount_cents).toBe(0);
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
expect(stripeHandlers.spies.cancelledSubscriptions).toContain(stripeSubscriptionId);
});
});
@@ -2,6 +2,7 @@
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import {getUserActivityBuffer} from '../../middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
@@ -19,6 +20,7 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.OK)
.execute();
await getUserActivityBuffer().drainAndFlush();
}
describe('Admin last active IP search', () => {
@@ -3,6 +3,7 @@
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import {createDmChannel, createFriendship, createGuild} from '../../channel/tests/ChannelTestUtils';
import {getUserActivityBuffer} from '../../middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder} from '../../test/TestRequestBuilder';
@@ -13,6 +14,7 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.OK)
.execute();
await getUserActivityBuffer().drainAndFlush();
}
describe('Admin Search Endpoints', () => {
@@ -5,6 +5,7 @@ import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUser
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import {createGuild} from '../../channel/tests/ChannelTestUtils';
import {getUserActivityBuffer} from '../../middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
@@ -40,6 +41,7 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
.header('x-forwarded-for', ip)
.expect(HTTP_STATUS.OK)
.execute();
await getUserActivityBuffer().drainAndFlush();
}
describe('Admin Search Field Coverage', () => {
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
import {createTestAccount, createUniqueEmail, createUniqueUsername, setUserACLs} from '../../auth/tests/AuthTestUtils';
import {setupTestGuildWithMembers} from '../../guild/tests/GuildTestUtils';
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
interface PendingRegistrationResponse {
user_id: string;
}
describe('pending registration approval and the stock community', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness.shutdown();
});
it('keeps a banned user out of the stock community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await getInstanceConfigRepository().setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: guild.id,
});
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('bannedapproval'),
username: createUniqueUsername('bannedapproval'),
global_name: 'The banned man',
password: 'approving-since-1999',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
await createBuilder(harness, owner.token)
.put(`/guilds/${guild.id}/bans/${pending.user_id}`)
.body({})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, admin.token)
.post('/admin/instance-config/pending-registrations/approve')
.body({user_id: pending.user_id})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('does not add a user who was never pending to the stock community', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const outsider = await createTestAccount(harness);
await getInstanceConfigRepository().setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: guild.id,
});
await createBuilder(harness, admin.token)
.post('/admin/instance-config/pending-registrations/approve')
.body({user_id: outsider.userId})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilder(harness, owner.token)
.get(`/guilds/${guild.id}/members/${outsider.userId}`)
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
});
+2 -12
View File
@@ -34,6 +34,7 @@ import {VisionarySlotInitializer} from '../stripe/VisionarySlotInitializer';
import {VoiceDataInitializer} from '../voice/VoiceDataInitializer';
import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
import {WorkerService} from '../worker/WorkerService';
import {ensureDeletionQueueState} from './DeletionQueueStartup';
let jsConnectionManager: JetStreamConnectionManager | null = null;
@@ -133,18 +134,7 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
setInjectedWorkerService(new WorkerService(workerQueue, getSnowflakeService(), new JobLedgerRepository()));
logger.info('JetStream worker service initialized');
}
try {
const kvDeletionQueue = getKVAccountDeletionQueue();
if (await kvDeletionQueue.needsRebuild()) {
logger.info('KV deletion queue needs rebuild, rebuilding...');
await kvDeletionQueue.rebuildState();
} else {
logger.info('KV deletion queue state is healthy');
}
} catch (error) {
logger.error({error}, 'Failed to verify KV deletion queue state');
throw error;
}
await ensureDeletionQueueState(getKVAccountDeletionQueue(), logger);
logger.info('Initializing search indexes...');
let searchInitialized = false;
try {
@@ -24,7 +24,6 @@ import {getCacheService} from '../middleware/ServiceSingletons';
import {OAuth2ApplicationsController} from '../oauth/OAuth2ApplicationsController';
import {OAuth2Controller} from '../oauth/OAuth2Controller';
import {OpenAPIController} from '../openapi/OpenAPIController';
import {registerPackControllers} from '../pack/controllers/index';
import {PremiumController} from '../premium/PremiumController';
import {ReadStateController} from '../read_state/ReadStateController';
import {ReportController} from '../report/ReportController';
@@ -54,7 +53,6 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
FavoriteGifController(routes);
FavoriteMemeController(routes);
InviteController(routes);
registerPackControllers(routes);
ReadStateController(routes);
ReportController(routes);
GuildController(routes);
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ILogger} from '../ILogger';
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
export async function ensureDeletionQueueState(
deletionQueue: KVAccountDeletionQueueService,
logger: ILogger,
): Promise<void> {
try {
if (!(await deletionQueue.needsRebuild())) {
logger.info('KV deletion queue state is healthy');
return;
}
} catch (error) {
logger.error({error}, 'Failed to read KV deletion queue state, aborting startup');
throw error;
}
let lockToken: string | null;
try {
lockToken = await deletionQueue.acquireRebuildLock();
} catch (error) {
logger.error({error}, 'Failed to acquire the KV deletion queue rebuild lock, aborting startup');
throw error;
}
if (!lockToken) {
logger.info('Another instance is rebuilding the KV deletion queue, skipping');
return;
}
logger.info('KV deletion queue needs rebuild, rebuilding...');
try {
await deletionQueue.rebuildState(lockToken);
} catch (error) {
logger.error({error}, 'KV deletion queue rebuild failed, leaving the rebuild to the deletion worker');
} finally {
try {
await deletionQueue.releaseRebuildLock(lockToken);
} catch (error) {
logger.error({error}, 'Failed to release the KV deletion queue rebuild lock');
}
}
}
+18 -1
View File
@@ -9,6 +9,7 @@ import {resolveClientIpHeaderName} from '@fluxer/ip_utils/src/ClientIp';
import type {ILogger} from '../ILogger';
import {ClientErrorAbuseSignalMiddleware} from '../middleware/AbusiveIpAutoBanner';
import {AuditLogMiddleware} from '../middleware/AuditLogMiddleware';
import {ConcurrencyLimitMiddleware} from '../middleware/ConcurrencyLimitMiddleware';
import ContentFilterMiddleware from '../middleware/ContentFilterMiddleware';
import {GuildAvailabilityMiddleware} from '../middleware/GuildAvailabilityMiddleware';
import {IpBanMiddleware} from '../middleware/IpBanMiddleware';
@@ -27,19 +28,35 @@ interface MiddlewarePipelineOptions {
corsOrigins: Array<string>;
trustClientIpHeader: boolean;
clientIpHeaderName?: string;
maxInflightRequests: number;
}
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName} = options;
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
routes.use(
'/.well-known/fluxer',
cors({
origins: '*',
methods: ['GET', 'HEAD', 'OPTIONS'],
allowedHeaders: [
HttpHeaders.ACCEPT,
HttpHeaders.CONTENT_TYPE,
HttpHeaders.IF_MODIFIED_SINCE,
HttpHeaders.IF_NONE_MATCH,
],
exposedHeaders: [HttpHeaders.ETAG, HttpHeaders.LAST_MODIFIED],
}),
);
applyMiddlewareStack(routes, {
requestId: {},
cors: {origins: corsOrigins, exposedHeaders: [HttpHeaders.X_FLUXER_VERSION]},
skipLogger: true,
skipErrorHandler: true,
});
routes.use(ConcurrencyLimitMiddleware({maxInflightRequests}));
routes.get('/_health', async (ctx) => ctx.text('OK'));
routes.use(IpBanMiddleware);
routes.use(
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {EMPTY_USER_ROW} from '../../database/types/UserTypes';
import type {ILogger} from '../../ILogger';
import {KVAccountDeletionQueueService} from '../../infrastructure/KVAccountDeletionQueueService';
import {User} from '../../models/User';
import {MockKVProvider} from '../../test/mocks/MockKVProvider';
import {NoopLogger} from '../../test/mocks/NoopLogger';
import type {UserRepository} from '../../user/repositories/UserRepository';
import {ensureDeletionQueueState} from '../DeletionQueueStartup';
const WORKER_PAGE_COUNT = 2;
class RecordingLogger implements ILogger {
readonly errors: Array<string> = [];
trace(): void {}
debug(): void {}
info(): void {}
warn(): void {}
fatal(): void {}
error(obj: object | string, msg?: string): void {
this.errors.push(typeof obj === 'string' ? obj : (msg ?? ''));
}
child(): ILogger {
return this;
}
}
class UnreadableStateKVProvider extends MockKVProvider {
override async exists(): Promise<number> {
throw new Error('kv unavailable');
}
}
class UnlockableKVProvider extends MockKVProvider {
override async acquireLock(): Promise<boolean> {
throw new Error('kv lock unavailable');
}
}
class UnreleasableKVProvider extends MockKVProvider {
override async releaseLock(): Promise<boolean> {
throw new Error('kv lock release failed');
}
}
function createFailingRepository(): UserRepository {
return {
async scanAllUsersPage() {
throw new Error('paged user scan failed');
},
} as unknown as UserRepository;
}
function createPendingUser(index: number): User {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(BigInt(9100 + index)),
pending_deletion_at: new Date('2026-06-01T00:00:00.000Z'),
deletion_reason_code: 0,
});
}
function createWorkerRepository(onPageStart: (page: number) => Promise<void>): UserRepository {
let page = 0;
return {
async scanAllUsersPage() {
page += 1;
await onPageStart(page);
return {
users: [createPendingUser(page)],
pageState: page < WORKER_PAGE_COUNT ? `page-${page}` : null,
};
},
} as unknown as UserRepository;
}
describe('ensureDeletionQueueState', () => {
it('leaves a rebuild owned by another instance alone', async () => {
const kvClient = new MockKVProvider();
let apiScans = 0;
const apiRepository = {
async scanAllUsersPage() {
apiScans += 1;
throw new Error('api pod scan failed');
},
} as unknown as UserRepository;
const apiQueue = new KVAccountDeletionQueueService(kvClient, apiRepository);
const apiFailures: Array<unknown> = [];
const workerQueue = new KVAccountDeletionQueueService(
kvClient,
createWorkerRepository(async (page) => {
if (page !== WORKER_PAGE_COUNT) {
return;
}
try {
await ensureDeletionQueueState(apiQueue, new NoopLogger());
} catch (error) {
apiFailures.push(error);
}
}),
);
const workerToken = await workerQueue.acquireRebuildLock();
expect(workerToken).not.toBeNull();
await workerQueue.rebuildState(workerToken);
expect(await workerQueue.releaseRebuildLock(workerToken!)).toBe(true);
const queued = await workerQueue.getReadyDeletions(Date.parse('2026-06-02T00:00:00.000Z'), 10);
expect(queued.map((deletion) => deletion.userId).sort()).toEqual([9101n, 9102n]);
expect(apiScans).toBe(0);
expect(apiFailures).toEqual([]);
});
it('does not abort startup when the paged user scan fails', async () => {
const kvClient = new MockKVProvider();
let scans = 0;
const repository = {
async scanAllUsersPage() {
scans += 1;
throw new Error('paged user scan failed');
},
} as unknown as UserRepository;
const queue = new KVAccountDeletionQueueService(kvClient, repository);
const logger = new RecordingLogger();
await expect(ensureDeletionQueueState(queue, logger)).resolves.toBeUndefined();
expect(scans).toBe(1);
expect(logger.errors).toEqual(['KV deletion queue rebuild failed, leaving the rebuild to the deletion worker']);
expect(await queue.acquireRebuildLock()).not.toBeNull();
});
it('aborts startup when the queue state cannot be read', async () => {
const queue = new KVAccountDeletionQueueService(new UnreadableStateKVProvider(), createFailingRepository());
const logger = new RecordingLogger();
await expect(ensureDeletionQueueState(queue, logger)).rejects.toThrow('kv unavailable');
expect(logger.errors).toEqual(['Failed to read KV deletion queue state, aborting startup']);
});
it('aborts startup when the rebuild lock cannot be acquired', async () => {
const queue = new KVAccountDeletionQueueService(new UnlockableKVProvider(), createFailingRepository());
const logger = new RecordingLogger();
await expect(ensureDeletionQueueState(queue, logger)).rejects.toThrow('kv lock unavailable');
expect(logger.errors).toEqual(['Failed to acquire the KV deletion queue rebuild lock, aborting startup']);
});
it('does not abort startup when releasing the rebuild lock fails', async () => {
const queue = new KVAccountDeletionQueueService(
new UnreleasableKVProvider(),
createWorkerRepository(async () => {}),
);
const logger = new RecordingLogger();
await expect(ensureDeletionQueueState(queue, logger)).resolves.toBeUndefined();
const queued = await queue.getReadyDeletions(Date.parse('2026-06-02T00:00:00.000Z'), 10);
expect(queued.map((deletion) => deletion.userId).sort()).toEqual([9101n, 9102n]);
expect(logger.errors).toEqual(['Failed to release the KV deletion queue rebuild lock']);
});
it('rebuilds under the lock when no other instance holds it', async () => {
const kvClient = new MockKVProvider();
const queue = new KVAccountDeletionQueueService(
kvClient,
createWorkerRepository(async () => {}),
);
await ensureDeletionQueueState(queue, new NoopLogger());
const queued = await queue.getReadyDeletions(Date.parse('2026-06-02T00:00:00.000Z'), 10);
expect(queued.map((deletion) => deletion.userId).sort()).toEqual([9101n, 9102n]);
expect(await queue.acquireRebuildLock()).not.toBeNull();
});
});
@@ -1,7 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AttachmentID, ChannelID, MessageID} from '../BrandedTypes';
import {BatchBuilder, fetchMany, fetchManyInChunks, fetchOne} from '../database/CassandraQueryExecution';
import {
BatchBuilder,
deleteOneOrMany,
fetchMany,
fetchManyInChunks,
fetchOne,
} from '../database/CassandraQueryExecution';
import {AttachmentDecayByExpiry, AttachmentDecayById} from '../Tables';
import type {AttachmentDecayRow} from '../types/AttachmentDecayTypes';
@@ -73,6 +79,20 @@ export class AttachmentDecayRepository {
return fetchMany(query.bind({expiry_bucket: bucket, current_time: currentTime}));
}
async deleteExpiryRecord(params: {
expiry_bucket: number;
expires_at: Date;
attachment_id: AttachmentID;
}): Promise<void> {
await deleteOneOrMany(
AttachmentDecayByExpiry.deleteByPk({
expiry_bucket: params.expiry_bucket,
expires_at: params.expires_at,
attachment_id: params.attachment_id,
}),
);
}
async deleteRecords(params: {expiry_bucket: number; expires_at: Date; attachment_id: AttachmentID}): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
+35 -1
View File
@@ -9,10 +9,12 @@ import {
AuthTokenWithUserIdResponse,
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
IpAuthorizationPollQuery,
IpAuthorizationPollResponse,
@@ -632,10 +634,39 @@ export function AuthController(app: HonoApp) {
return ctx.json(response);
},
);
app.post(
'/auth/handoff/:code/status',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_STATUS),
Validator('param', HandoffCodeParam),
Validator('json', HandoffStatusRequest),
OpenAPI({
operationId: 'get_handoff_status_with_secret',
summary: 'Get handoff status with secret',
responseSchema: HandoffStatusResponse,
statusCode: 200,
security: [],
tags: ['Auth'],
description:
'Check the status of a handoff session using the poll secret from initiation. Returns the authentication token once the handoff is complete and the presented secret matches.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
const response = await ctx.get('authRequestService').getHandoffStatus({
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
});
return ctx.json(response);
},
);
app.delete(
'/auth/handoff/:code',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_CANCEL),
Validator('param', HandoffCodeParam),
Validator('json', HandoffCancelRequest),
OpenAPI({
operationId: 'cancel_handoff',
summary: 'Cancel handoff',
@@ -646,7 +677,10 @@ export function AuthController(app: HonoApp) {
description: 'Cancel an ongoing handoff session. The handoff code will no longer be valid for authentication.',
}),
async (ctx) => {
await ctx.get('authRequestService').cancelHandoff({code: ctx.req.valid('param').code});
await ctx.get('authRequestService').cancelHandoff({
code: ctx.req.valid('param').code,
pollSecret: ctx.req.valid('json').poll_secret,
});
return ctx.body(null, 204);
},
);
+44 -34
View File
@@ -29,7 +29,7 @@ import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDel
import {REGISTRATION_PENDING_APPROVAL_TRAIT, REGISTRATION_REJECTED_TRAIT} from '../instance/InstanceConfigRepository';
import type {InviteService} from '../invite/InviteService';
import {Logger} from '../Logger';
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
import {createRequestCache} from '../middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
import type {User} from '../models/User';
import {lookupGeoip} from '../utils/IpUtils';
@@ -38,18 +38,8 @@ import * as AuthPassword from './AuthPassword';
import * as AuthSession from './AuthSession';
import * as AuthUtility from './AuthUtility';
function createRequestCache(): RequestCache {
const userPartials = new Map();
const messageMentionChannels = new Map();
return {
userPartials,
messageMentionChannels,
clear: () => {
userPartials.clear();
messageMentionChannels.clear();
},
};
}
const DUMMY_ARGON2_HASH =
'$argon2id$v=19$m=65536,t=3,p=4$fT6tGpAyxFiz+n1RbkRqWQ$v05UT17QGeqhsgRjcVjIWcGw6gUDYeCcAA8FiZ63MtA';
interface LoginParams {
data: LoginRequest;
@@ -207,7 +197,7 @@ export async function login(
const {inviteService, kvDeletionQueue} = deps;
const skipRateLimits = config.dev.testModeEnabled || config.dev.disableRateLimits;
const emailRateLimit = await rateLimit.checkLimit({
identifier: `login:email:${data.email}`,
identifier: `login:email:${data.email.toLowerCase()}`,
maxAttempts: 5,
windowMs: ms('15 minutes'),
});
@@ -234,9 +224,16 @@ export async function login(
]);
}
AuthUtility.assertNonBotUser(ctx, user);
if (!user.passwordHash) {
await AuthPassword.verifyPassword(ctx, {password: data.password, passwordHash: DUMMY_ARGON2_HASH});
throw InputValidationError.fromCodes([
{path: 'email', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
{path: 'password', code: ValidationErrorCodes.INVALID_EMAIL_OR_PASSWORD},
]);
}
const isMatch = await AuthPassword.verifyPassword(ctx, {
password: data.password,
passwordHash: user.passwordHash!,
passwordHash: user.passwordHash,
});
if (!isMatch) {
throw InputValidationError.fromCodes([
@@ -359,13 +356,36 @@ export async function login(
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
async function consumeMfaAttempt(
ctx: ApiContext,
{userId, ticket, field}: {userId: string; ticket: string; field: string},
): Promise<void> {
const {cache, rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `mfa:user:${userId}`,
maxAttempts: MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
const ticketLimit = await rateLimit.checkLimit({
identifier: `mfa:ticket:${ticket}`,
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
windowMs: ms('5 minutes'),
});
if (!ticketLimit.allowed) {
await cache.delete(`mfa-ticket:${ticket}`);
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
}
export async function loginMfaTotp(
ctx: ApiContext,
{code, ticket, request}: LoginMfaTotpParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -378,32 +398,19 @@ export async function loginMfaTotp(
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
if (!isValid) {
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
} else {
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
}
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
@@ -415,7 +422,7 @@ export async function loginMfaWebAuthn(
ctx: ApiContext,
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -425,8 +432,11 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
+20 -2
View File
@@ -21,7 +21,7 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import {Logger} from '../Logger';
@@ -79,7 +79,7 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
return true;
}
const reuseKey = `mfa-totp:${userId}:${code}`;
const lockToken = await cache.acquireLock(reuseKey, seconds('30 seconds'));
const lockToken = await cache.acquireLock(reuseKey, seconds('90 seconds'));
if (lockToken) {
return true;
}
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
return options;
}
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
const {rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `sudo-mfa:user:${userId}`,
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
}
}
export async function verifySudoMfa(
ctx: ApiContext,
params: SudoMfaVerificationParams,
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
case 'totp': {
if (!code) return {success: false, error: 'TOTP code is required'};
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
await consumeSudoMfaAttempt(ctx, userId);
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
if (isValid) {
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
}
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
}
case 'webauthn': {
+30 -26
View File
@@ -20,8 +20,11 @@ import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil}
import * as AuthSession from './AuthSession';
import * as AuthUtility from './AuthUtility';
const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;
interface CacheEntry {
result: boolean;
pwnedSuffixes: ReadonlySet<string>;
expiresAt: number;
}
@@ -30,34 +33,34 @@ class PwnedPasswordCache {
private readonly maxSize: number;
private readonly ttlMs: number;
constructor(maxSize = 1000, ttlMs = ms('1 hour')) {
constructor(maxSize = PWNED_PASSWORD_CACHE_MAX_PREFIXES, ttlMs = ms('1 hour')) {
this.maxSize = maxSize;
this.ttlMs = ttlMs;
}
get(key: string): boolean | undefined {
const entry = this.cache.get(key);
get(hashPrefix: string): ReadonlySet<string> | undefined {
const entry = this.cache.get(hashPrefix);
if (!entry) {
return undefined;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
this.cache.delete(hashPrefix);
return undefined;
}
this.cache.delete(key);
this.cache.set(key, entry);
return entry.result;
this.cache.delete(hashPrefix);
this.cache.set(hashPrefix, entry);
return entry.pwnedSuffixes;
}
set(key: string, result: boolean): void {
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
set(hashPrefix: string, pwnedSuffixes: ReadonlySet<string>): void {
if (this.cache.size >= this.maxSize && !this.cache.has(hashPrefix)) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, {
result,
this.cache.set(hashPrefix, {
pwnedSuffixes,
expiresAt: Date.now() + this.ttlMs,
});
}
@@ -95,7 +98,11 @@ type ResetPasswordResult =
webauthn: boolean;
};
const pwnedPasswordCache = new PwnedPasswordCache(1000, ms('1 hour'));
const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));
export function resetPwnedPasswordCacheForTesting(): void {
pwnedPasswordCache.clear();
}
export async function hashPassword(_ctx: ApiContext, password: string): Promise<string> {
return hashPasswordUtil(password);
@@ -112,9 +119,9 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const hashPrefix = hashed.slice(0, 5);
const hashSuffix = hashed.slice(5);
const cachedResult = pwnedPasswordCache.get(hashed);
if (cachedResult !== undefined) {
return cachedResult;
const cachedSuffixes = pwnedPasswordCache.get(hashPrefix);
if (cachedSuffixes !== undefined) {
return cachedSuffixes.has(hashSuffix);
}
try {
const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
@@ -122,6 +129,7 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
'User-Agent': FLUXER_USER_AGENT,
'Add-Padding': 'true',
},
signal: AbortSignal.timeout(PWNED_PASSWORDS_TIMEOUT_MS),
});
if (!response.ok) {
Logger.warn(
@@ -153,20 +161,16 @@ export async function isPasswordPwned(_ctx: ApiContext, password: string): Promi
);
}
const limit = Math.min(lines.length, MAX_PWNED_LINES);
const pwnedSuffixes = new Set<string>();
for (let i = 0; i < limit; i++) {
const line = lines[i];
const [hashSuffixLine, count] = line.split(':', 2);
if (
hashSuffixLine.length === hashSuffix.length &&
crypto.timingSafeEqual(Buffer.from(hashSuffixLine), Buffer.from(hashSuffix)) &&
Number.parseInt(count, 10) > 0
) {
pwnedPasswordCache.set(hashed, true);
return true;
if (hashSuffixLine.length === hashSuffix.length && Number.parseInt(count, 10) > 0) {
pwnedSuffixes.add(hashSuffixLine);
}
}
pwnedPasswordCache.set(hashed, false);
return false;
pwnedPasswordCache.set(hashPrefix, pwnedSuffixes);
return pwnedSuffixes.has(hashSuffix);
} catch (error) {
Logger.error({error}, 'Failed to check password against Pwned Passwords API');
return false;
@@ -267,7 +271,7 @@ export async function resetPassword(
},
user.toRow(),
);
await users.deleteAllAuthSessions(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
+12 -5
View File
@@ -133,6 +133,12 @@ interface AuthHandoffInfoRequest {
interface AuthHandoffStatusRequest {
code: string;
clientIp: string;
pollSecret?: string;
}
interface AuthHandoffCancelRequest {
code: string;
pollSecret: string;
}
export class AuthRequestService {
@@ -182,7 +188,7 @@ export class AuthRequestService {
}
async logout({authorizationHeader, authToken}: AuthLogoutRequest): Promise<void> {
const token = authorizationHeader ?? authToken;
const token = authToken ?? authorizationHeader;
if (token) {
await AuthSession.revokeToken(this.apiContext, token);
}
@@ -305,6 +311,7 @@ export class AuthRequestService {
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
poll_secret: result.pollSecret,
};
}
@@ -352,8 +359,8 @@ export class AuthRequestService {
);
}
async getHandoffStatus({code, clientIp}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp);
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
return {
status: result.status,
token: result.token,
@@ -362,8 +369,8 @@ export class AuthRequestService {
};
}
async cancelHandoff({code}: {code: string}): Promise<void> {
await this.desktopHandoffService.cancelHandoff(code);
async cancelHandoff({code, pollSecret}: AuthHandoffCancelRequest): Promise<void> {
await this.desktopHandoffService.cancelHandoff(code, pollSecret);
}
private async getUserPartial(userId: string): Promise<UserPartialResponse> {
+1 -14
View File
@@ -35,15 +35,6 @@ interface LogoutAuthSessionsParams {
sessionIdHashes: Array<string>;
}
interface UpdateUserActivityParams {
userId: UserID;
clientIp: string;
user?: User;
action?: 'session_authenticated' | 'bearer_fallback_session_authenticated' | 'unknown';
tokenType?: 'session' | 'bearer';
sessionId?: string;
}
interface DispatchAuthSessionChangeParams {
userId: UserID;
oldAuthSessionIdHash: string;
@@ -91,6 +82,7 @@ export async function createAuthSession(
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
user = await AuthUtility.handleBanStatus(ctx, user);
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
let clientCountry: string | null = null;
@@ -152,11 +144,6 @@ export async function updateAuthSessionLastUsed(ctx: ApiContext, tokenHash: Uint
await ctx.services.userActivityBuffer.recordAuthSessionActivity(Buffer.from(tokenHash), new Date());
}
export async function updateUserActivity(ctx: ApiContext, {userId, clientIp}: UpdateUserActivityParams): Promise<void> {
const {users} = ctx.services;
await users.updateUserActivity(userId, clientIp);
}
export async function revokeToken(ctx: ApiContext, token: string): Promise<void> {
const {users, gateway} = ctx.services;
const tokenHash = Buffer.from(AuthUtility.getTokenIdHash(ctx, token));
@@ -1,60 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomBytes} from 'node:crypto';
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCodeExpiredError';
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
import {
DESKTOP_HANDOFF_CODE_ALPHABET,
DESKTOP_HANDOFF_CODE_LENGTH,
formatDesktopHandoffCode,
parseDesktopHandoffCode,
} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import type {SessionOrigin} from '../AuthSession';
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 12;
const NORMALIZED_CODE_REGEX = /^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{12}$/;
const HANDOFF_ATTEMPT_PREFIX = 'desktop-handoff-attempts:';
const HANDOFF_APPROVER_PREFIX = 'desktop-handoff-approver:';
const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_TTL_SECONDS = 900;
const MAX_INFO_LOOKUPS = 3;
const POLL_SECRET_BYTES = 32;
interface HandoffData {
createdAt: number;
origin: SessionOrigin;
infoLookupCount: number;
pollSecretHash: string;
}
interface HandoffTokenData {
token: string;
userId: string;
pollSecretHash: string;
}
interface HandoffApproverData {
approvedAt: number;
}
function generateHandoffCode(): string {
const maxUnbiased = 256 - (256 % CODE_CHARACTERS.length);
function generateNormalizedHandoffCode(): string {
const maxUnbiased = 256 - (256 % DESKTOP_HANDOFF_CODE_ALPHABET.length);
let code = '';
while (code.length < CODE_LENGTH) {
const bytes = randomBytes(CODE_LENGTH - code.length);
for (let i = 0; i < bytes.length && code.length < CODE_LENGTH; i++) {
while (code.length < DESKTOP_HANDOFF_CODE_LENGTH) {
const bytes = randomBytes(DESKTOP_HANDOFF_CODE_LENGTH - code.length);
for (let i = 0; i < bytes.length && code.length < DESKTOP_HANDOFF_CODE_LENGTH; i++) {
if (bytes[i] < maxUnbiased) {
code += CODE_CHARACTERS[bytes[i] % CODE_CHARACTERS.length];
code += DESKTOP_HANDOFF_CODE_ALPHABET[bytes[i] % DESKTOP_HANDOFF_CODE_ALPHABET.length];
}
}
}
return `${code.slice(0, 6)}-${code.slice(6, 12)}`;
return code;
}
function normalizeHandoffCode(code: string): string {
return code.replace(/[-\s]/g, '').toUpperCase();
}
function assertValidHandoffCode(code: string): void {
if (!NORMALIZED_CODE_REGEX.test(code)) {
function requireNormalizedHandoffCode(code: string): string {
const normalized = parseDesktopHandoffCode(code);
if (normalized == null) {
throw new InvalidHandoffCodeError();
}
return normalized;
}
function generatePollSecret(): string {
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
}
function hashPollSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
if (!presented || !storedHash) {
return false;
}
const presentedHash = Buffer.from(hashPollSecret(presented), 'hex');
const stored = Buffer.from(storedHash, 'hex');
if (presentedHash.length !== stored.length) {
return false;
}
return timingSafeEqual(presentedHash, stored);
}
export class DesktopHandoffService {
@@ -63,19 +87,21 @@ export class DesktopHandoffService {
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
}> {
const {cache} = this.apiContext.services;
const code = generateHandoffCode();
const normalizedCode = normalizeHandoffCode(code);
const normalizedCode = generateNormalizedHandoffCode();
const pollSecret = generatePollSecret();
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
};
const expirySeconds = seconds('5 minutes');
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
const expiresAt = new Date(Date.now() + ms('5 minutes'));
return {code, expiresAt};
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
}
async completeHandoff(
@@ -84,8 +110,7 @@ export class DesktopHandoffService {
approverIp: string,
): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const storedApprover = await cache.get<HandoffApproverData>(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
if (!storedApprover) {
@@ -108,6 +133,7 @@ export class DesktopHandoffService {
const tokenData: HandoffTokenData = {
token,
userId,
pollSecretHash: handoffData.pollSecretHash,
};
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
@@ -122,8 +148,7 @@ export class DesktopHandoffService {
origin?: SessionOrigin;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
@@ -149,18 +174,23 @@ export class DesktopHandoffService {
async getHandoffStatus(
code: string,
_pollerIp: string,
pollerIp: string,
pollSecret: string | undefined,
): Promise<{
status: 'pending' | 'completed' | 'expired';
token?: string;
userId?: string;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(pollerIp);
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
if (tokenData) {
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
await cache.delete(tokenKey);
return {
status: 'completed',
@@ -175,12 +205,19 @@ export class DesktopHandoffService {
return {status: 'expired'};
}
async cancelHandoff(code: string): Promise<void> {
async cancelHandoff(code: string, pollSecret: string): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = normalizeHandoffCode(code);
assertValidHandoffCode(normalizedCode);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await cache.delete(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`);
const normalizedCode = requireNormalizedHandoffCode(code);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
if (!pollSecretMatches(pollSecret, storedHash)) {
throw new InvalidHandoffCodeError();
}
await cache.delete(codeKey);
await cache.delete(tokenKey);
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHmac, randomBytes, randomUUID} from 'node:crypto';
import {getSameIpDecisionKey, getSubnet} from '@fluxer/ip_utils/src/IpAddress';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
import {Logger} from '../../Logger';
@@ -19,7 +20,7 @@ interface PhoneAttemptRiskThresholds {
userDistinctCountriesRequireInbound: number;
ipDistinctPhonesHardBlock: number;
ipDistinctUsersHardBlock: number;
cidr24DistinctPhonesHardBlock: number;
subnetDistinctPhonesHardBlock: number;
}
const DEFAULT_THRESHOLDS: PhoneAttemptRiskThresholds = {
@@ -28,7 +29,7 @@ const DEFAULT_THRESHOLDS: PhoneAttemptRiskThresholds = {
userDistinctCountriesRequireInbound: 3,
ipDistinctPhonesHardBlock: 10,
ipDistinctUsersHardBlock: 3,
cidr24DistinctPhonesHardBlock: 30,
subnetDistinctPhonesHardBlock: 30,
};
type PhoneAttemptRiskDecision = 'allow' | 'require_captcha' | 'require_inbound' | 'hard_block';
@@ -38,10 +39,10 @@ type PhoneAttemptRiskReason =
| 'user_country_sweep'
| 'ip_distinct_phones'
| 'ip_user_pool'
| 'cidr24_distinct_phones'
| 'subnet_distinct_phones'
| 'user_already_hard_blocked'
| 'ip_already_hard_blocked'
| 'cidr24_already_hard_blocked';
| 'subnet_already_hard_blocked';
interface PhoneAttemptRiskEvaluation {
decision: PhoneAttemptRiskDecision;
@@ -53,7 +54,7 @@ interface PhoneAttemptRiskEvaluation {
userDistinctCountries: number;
ipDistinctPhones: number;
ipDistinctUsers: number;
cidr24DistinctPhones: number;
subnetDistinctPhones: number;
};
}
@@ -64,7 +65,7 @@ const ZERO_COUNTERS: PhoneAttemptRiskEvaluation['counters'] = {
userDistinctCountries: 0,
ipDistinctPhones: 0,
ipDistinctUsers: 0,
cidr24DistinctPhones: 0,
subnetDistinctPhones: 0,
};
interface PhoneAttemptRiskInput {
@@ -105,23 +106,16 @@ function prefix6(phone: string): string {
return phone.slice(0, 6);
}
function cidr24For(ip: string): string | null {
if (!ip.includes('.')) return null;
const parts = ip.split('.');
if (parts.length !== 4) return null;
return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
}
function userKey(userId: string | bigint, suffix: string): string {
return `${KEY_PREFIX}user:${String(userId)}:${suffix}`;
}
function ipKey(ip: string, suffix: string): string {
return `${KEY_PREFIX}ip:${ip}:${suffix}`;
return `${KEY_PREFIX}ip:${getSameIpDecisionKey(ip) ?? ip}:${suffix}`;
}
function cidr24Key(cidr: string, suffix: string): string {
return `${KEY_PREFIX}ip24:${cidr}:${suffix}`;
function subnetKey(subnet: string, suffix: string): string {
return `${KEY_PREFIX}ipnet:${subnet}:${suffix}`;
}
type PhoneAttemptHardBlockHook = (params: {
@@ -166,10 +160,10 @@ export class PhoneAttemptRiskService {
evaluation.reason = 'ip_already_hard_blocked';
return evaluation;
}
const cidr = cidr24For(input.clientIp);
if (cidr && (await this.kv.exists(cidr24Key(cidr, 'hard_block'))) > 0) {
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
if ((await this.kv.exists(subnetKey(subnet, 'hard_block'))) > 0) {
evaluation.decision = 'hard_block';
evaluation.reason = 'cidr24_already_hard_blocked';
evaluation.reason = 'subnet_already_hard_blocked';
return evaluation;
}
}
@@ -212,15 +206,13 @@ export class PhoneAttemptRiskService {
evaluation.reason = 'ip_user_pool';
return evaluation;
}
const cidr = cidr24For(input.clientIp);
if (cidr) {
const cidr24DistinctPhones = await this.kv.scard(cidr24Key(cidr, 'phones'));
evaluation.counters.cidr24DistinctPhones = cidr24DistinctPhones;
if (cidr24DistinctPhones >= this.thresholds.cidr24DistinctPhonesHardBlock) {
evaluation.decision = 'hard_block';
evaluation.reason = 'cidr24_distinct_phones';
return evaluation;
}
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
const subnetDistinctPhones = await this.kv.scard(subnetKey(subnet, 'phones'));
evaluation.counters.subnetDistinctPhones = subnetDistinctPhones;
if (subnetDistinctPhones >= this.thresholds.subnetDistinctPhonesHardBlock) {
evaluation.decision = 'hard_block';
evaluation.reason = 'subnet_distinct_phones';
return evaluation;
}
}
return evaluation;
@@ -248,16 +240,14 @@ export class PhoneAttemptRiskService {
}
let ipDistinctPhones = 0;
let ipDistinctUsers = 0;
let cidr24DistinctPhones = 0;
let subnetDistinctPhones = 0;
if (input.clientIp) {
ipDistinctPhones = await this.bumpSet(ipKey(input.clientIp, 'phones'), hmac(input.phone, key));
if (input.userId !== null && input.userId !== undefined && input.userId !== '') {
ipDistinctUsers = await this.bumpSet(ipKey(input.clientIp, 'users'), hmac(String(input.userId), key));
}
const cidr = cidr24For(input.clientIp);
if (cidr) {
cidr24DistinctPhones = await this.bumpSet(cidr24Key(cidr, 'phones'), hmac(input.phone, key));
}
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
subnetDistinctPhones = await this.bumpSet(subnetKey(subnet, 'phones'), hmac(input.phone, key));
}
const counters = {
userAttempts,
@@ -266,7 +256,7 @@ export class PhoneAttemptRiskService {
userDistinctCountries,
ipDistinctPhones,
ipDistinctUsers,
cidr24DistinctPhones,
subnetDistinctPhones,
};
const tripped = this.tripsAt(counters);
if (tripped !== null) {
@@ -290,8 +280,8 @@ export class PhoneAttemptRiskService {
) {
return 'ip_user_pool';
}
if (counters.cidr24DistinctPhones >= this.thresholds.cidr24DistinctPhonesHardBlock) {
return 'cidr24_distinct_phones';
if (counters.subnetDistinctPhones >= this.thresholds.subnetDistinctPhonesHardBlock) {
return 'subnet_distinct_phones';
}
return null;
}
@@ -306,11 +296,9 @@ export class PhoneAttemptRiskService {
await this.kv.setex(userKey(input.userId, 'hard_block'), HARD_BLOCK_TTL_SECONDS, '1');
} else if (reason === 'ip_user_pool' && input.clientIp) {
await this.kv.setex(ipKey(input.clientIp, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
} else if (reason === 'cidr24_distinct_phones' && input.clientIp) {
const cidr = cidr24For(input.clientIp);
if (cidr) {
await this.kv.setex(cidr24Key(cidr, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
}
} else if (reason === 'subnet_distinct_phones' && input.clientIp) {
const subnet = getSubnet(input.clientIp) ?? input.clientIp;
await this.kv.setex(subnetKey(subnet, 'hard_block'), IP_BLOCK_TTL_SECONDS, '1');
}
Logger.warn(
{
+21 -14
View File
@@ -24,6 +24,7 @@ import {
} from 'jose';
import type {ApiContext} from '../../ApiContext';
import type {UserID} from '../../BrandedTypes';
import type {ILogger} from '../../ILogger';
import type {IDiscriminatorService} from '../../infrastructure/DiscriminatorService';
import type {KVActivityTracker} from '../../infrastructure/KVActivityTracker';
import {
@@ -100,6 +101,13 @@ const STATE_BYTE_LENGTH = 16;
const NONCE_BYTE_LENGTH = 16;
const MOBILE_SSO_REDIRECT_URI = 'fluxer://auth/sso/callback';
let ssoLogger: ILogger | undefined;
function getLogger(): ILogger {
ssoLogger ??= Logger.child({logger: 'SsoService'});
return ssoLogger;
}
function randomBase64UrlToken(byteLength: number): string {
return randomBytes(byteLength).toString('base64url');
}
@@ -225,7 +233,7 @@ function resolveEmailVerified({
if (values.includes(false)) {
return false;
}
return values.length === 0 || values.includes(true);
return values.length > 0 && values.includes(true);
}
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
@@ -235,7 +243,6 @@ function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
}
export class SsoService {
private readonly logger = Logger.child({logger: 'SsoService'});
private static readonly STATE_TTL_SECONDS = seconds('10 minutes');
private static readonly DISCOVERY_TTL_SECONDS = seconds('1 hour');
private static readonly JWKS_CACHE_TTL_MS = ms('1 hour');
@@ -344,12 +351,12 @@ export class SsoService {
throw InputValidationError.fromCode('email_verified', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
const emailLower = claims.email.toLowerCase();
this.logger.info({email: emailLower, has_sub: true}, 'SSO login with sub claim');
getLogger().info({email: emailLower, has_sub: true}, 'SSO login with sub claim');
const identityUserId = await this.ssoIdentityRepository.findUserId(config.providerId, claims.sub);
if (identityUserId) {
const user = await this.apiContext.services.users.findUnique(identityUserId);
if (!user) {
this.logger.error(
getLogger().error(
{user_id: identityUserId.toString()},
'SSO identity mapping points at a missing user; refusing reassignment',
);
@@ -401,7 +408,7 @@ export class SsoService {
if (ownerId?.toString() === userId.toString()) {
return;
}
this.logger.error(
getLogger().error(
{user_id: userId.toString(), owner_user_id: ownerId?.toString() ?? null},
'SSO identity is already linked to another account',
);
@@ -413,7 +420,7 @@ export class SsoService {
const traits = user.traits;
const existingIdentities = Array.from(traits).filter((trait) => trait.startsWith('sso_identity:'));
if (existingIdentities.length > 0 && !traits.has(identityTrait)) {
this.logger.error({user_id: user.id.toString()}, 'SSO identity claim did not match linked account');
getLogger().error({user_id: user.id.toString()}, 'SSO identity claim did not match linked account');
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
await this.claimSsoIdentity(user.id, sub, config);
@@ -534,13 +541,13 @@ export class SsoService {
}),
);
void this.kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
this.logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
getLogger().warn({error, userId: user.id}, 'Failed to update real-time user activity');
});
return user;
} catch (error) {
if (!userCreated) {
await this.ssoIdentityRepository.releaseIdentity(config.providerId, claims.sub).catch((releaseError) => {
this.logger.error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
getLogger().error({releaseError}, 'Failed to release SSO identity after user provisioning failed');
});
}
throw error;
@@ -567,7 +574,7 @@ export class SsoService {
let claims: JWTPayload | null = null;
if (tokenResponse.id_token) {
if (!config.jwksUrl) {
this.logger.warn('SSO id_token returned but no JWKS URL is configured; ignoring id_token claims');
getLogger().warn('SSO id_token returned but no JWKS URL is configured; ignoring id_token claims');
} else {
claims = await this.verifyIdToken(tokenResponse.id_token, config, expectedNonce);
}
@@ -580,7 +587,7 @@ export class SsoService {
const userInfoSub = userInfo ? readStringClaim(userInfo, 'sub') : undefined;
if (claims && userInfo) {
if (!idTokenSub || !userInfoSub || idTokenSub !== userInfoSub) {
this.logger.error('SSO sub mismatch between id_token and userinfo');
getLogger().error('SSO sub mismatch between id_token and userinfo');
throw InputValidationError.fromCode('sub', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
}
@@ -597,7 +604,7 @@ export class SsoService {
const normalizedIdTokenEmail = idTokenEmail ? normalizeSsoEmail(idTokenEmail) : undefined;
const normalizedUserInfoEmail = userInfoEmail ? normalizeSsoEmail(userInfoEmail) : undefined;
if (normalizedIdTokenEmail && normalizedUserInfoEmail && normalizedIdTokenEmail !== normalizedUserInfoEmail) {
this.logger.error('SSO email mismatch between id_token and userinfo');
getLogger().error('SSO email mismatch between id_token and userinfo');
throw InputValidationError.fromCode('email', ValidationErrorCodes.SSO_IDENTITY_MISMATCH);
}
const email =
@@ -634,7 +641,7 @@ export class SsoService {
});
const nonce = payload['nonce'];
if (nonce === undefined) {
this.logger.warn('SSO id_token missing required nonce claim');
getLogger().warn('SSO id_token missing required nonce claim');
throw new Error('nonce missing');
}
if (typeof nonce !== 'string' || nonce.length === 0 || nonce !== expectedNonce) {
@@ -644,7 +651,7 @@ export class SsoService {
}
return decodeJwt(idToken);
} catch (error) {
this.logger.error({error}, 'Failed to verify SSO id_token');
getLogger().error({error}, 'Failed to verify SSO id_token');
throw InputValidationError.fromCode('id_token', ValidationErrorCodes.INVALID_SSO_TOKEN);
}
}
@@ -919,7 +926,7 @@ export class SsoService {
try {
return await this.assertPublicOutboundUrl(rawUrl, fieldName);
} catch (error) {
this.logger.warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
getLogger().warn({fieldName, rawUrl, error}, 'Ignoring SSO URL that failed outbound policy validation');
return null;
}
}

Some files were not shown because too many files have changed in this diff Show More