From 7375ac9d80389e698662b87edd477405d04e02eb Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 13 Sep 2026 17:38:50 +0200 Subject: [PATCH] docs: simplify the reference and tighten the verifier (#2736) --- .../src/features/app/hooks/useTextOverflow.ts | 75 +-- .../components/CompactMemberCustomStatus.tsx | 10 +- .../components/modals/AddConnectionModal.tsx | 2 +- .../navigation/utils/MobileNavigation.ts | 22 +- .../platform/utils/SharedResizeObserver.ts | 74 ++- .../src/features/ui/hooks/useTextOverflow.ts | 22 +- .../components/modals/tabs/LanguageTab.tsx | 24 +- fluxer_docs/astro.config.ts | 2 +- fluxer_docs/package.json | 5 + fluxer_docs/scripts/DocsRouteHeaders.ts | 120 ++++ fluxer_docs/scripts/DocsSource.ts | 52 +- fluxer_docs/scripts/DocsTableWidth.ts | 52 +- fluxer_docs/scripts/VerifyDocsCoverage.ts | 539 +++++++++--------- fluxer_docs/scripts/VerifyDocsSchemas.ts | 209 +++---- fluxer_docs/scripts/VerifyDocsStyle.ts | 54 +- .../src/content/docs/admin-api/api-keys.mdx | 4 +- .../content/docs/admin-api/applications.mdx | 4 +- .../src/content/docs/admin-api/archives.mdx | 10 +- .../src/content/docs/admin-api/blocklists.mdx | 12 +- .../src/content/docs/admin-api/bulk-jobs.mdx | 10 +- .../src/content/docs/admin-api/discovery.mdx | 5 +- .../src/content/docs/admin-api/gateway.mdx | 6 +- .../src/content/docs/admin-api/gift-codes.mdx | 4 +- .../src/content/docs/admin-api/guilds.mdx | 13 +- .../src/content/docs/admin-api/index.mdx | 2 +- .../src/content/docs/admin-api/instance.mdx | 42 +- .../src/content/docs/admin-api/jobs.mdx | 179 +++--- .../src/content/docs/admin-api/messages.mdx | 4 +- .../src/content/docs/admin-api/reports.mdx | 4 +- .../content/docs/admin-api/search-indexes.mdx | 16 +- .../src/content/docs/admin-api/system-dms.mdx | 8 +- .../src/content/docs/admin-api/users.mdx | 46 +- .../src/content/docs/admin-api/voice.mdx | 16 +- .../src/content/docs/authentication.md | 125 ++-- fluxer_docs/src/content/docs/conventions.md | 147 ----- .../src/content/docs/gateway/commands.md | 16 +- .../content/docs/gateway/event-filtering.md | 16 +- .../src/content/docs/gateway/events.md | 34 +- .../docs/gateway/limits-and-rate-limits.md | 32 +- .../docs/gateway/opcodes-and-close-codes.md | 52 +- .../src/content/docs/gateway/overview.md | 10 +- .../content/docs/http-api/applications.mdx | 14 +- .../content/docs/http-api/authentication.mdx | 58 +- .../src/content/docs/http-api/billing.mdx | 30 +- .../src/content/docs/http-api/calls.mdx | 20 +- .../src/content/docs/http-api/channels.mdx | 12 +- .../src/content/docs/http-api/connections.mdx | 247 ++------ .../docs/http-api/deployment-availability.md | 2 +- .../src/content/docs/http-api/discovery.mdx | 22 +- .../src/content/docs/http-api/donations.mdx | 16 +- .../src/content/docs/http-api/downloads.mdx | 54 +- .../content/docs/http-api/entrance-sounds.mdx | 18 +- .../src/content/docs/http-api/errors.md | 30 +- .../src/content/docs/http-api/experiments.mdx | 10 +- .../src/content/docs/http-api/expressions.mdx | 8 +- .../src/content/docs/http-api/gateway.mdx | 44 +- .../src/content/docs/http-api/gifs.mdx | 19 +- .../src/content/docs/http-api/gifts.mdx | 27 +- .../docs/http-api/guild-audit-logs.mdx | 16 +- .../content/docs/http-api/guild-channels.mdx | 22 +- .../content/docs/http-api/guild-emojis.mdx | 30 +- .../docs/http-api/guild-member-search.mdx | 22 +- .../content/docs/http-api/guild-members.mdx | 26 +- .../docs/http-api/guild-moderation.mdx | 16 +- .../content/docs/http-api/guild-stickers.mdx | 28 +- .../src/content/docs/http-api/guilds.mdx | 51 +- .../src/content/docs/http-api/index.md | 46 +- .../src/content/docs/http-api/instance.mdx | 10 +- .../src/content/docs/http-api/invites.mdx | 28 +- .../src/content/docs/http-api/memes.mdx | 16 +- .../src/content/docs/http-api/messages.mdx | 16 +- .../src/content/docs/http-api/oauth2.mdx | 6 +- .../src/content/docs/http-api/permissions.mdx | 6 +- .../src/content/docs/http-api/premium.mdx | 40 +- .../src/content/docs/http-api/read-states.mdx | 16 +- .../src/content/docs/http-api/reports.mdx | 6 +- .../src/content/docs/http-api/search.mdx | 8 +- .../src/content/docs/http-api/streams.mdx | 10 +- .../src/content/docs/http-api/themes.mdx | 4 +- .../src/content/docs/http-api/unfurl.mdx | 54 +- .../src/content/docs/http-api/users.mdx | 8 +- .../content/docs/http-api/users/content.mdx | 10 +- .../docs/http-api/users/current-user.mdx | 3 +- .../docs/http-api/users/data-harvest.mdx | 12 +- .../http-api/users/email-and-password.mdx | 5 +- .../src/content/docs/http-api/users/gifts.mdx | 2 +- .../src/content/docs/http-api/users/mfa.mdx | 19 +- .../http-api/users/phone-verification.mdx | 8 +- .../docs/http-api/users/relationships.mdx | 4 +- .../docs/http-api/users/settings-protobuf.md | 8 +- .../content/docs/http-api/users/settings.mdx | 6 +- .../src/content/docs/http-api/webhooks.mdx | 11 +- fluxer_docs/src/content/docs/index.md | 64 +-- .../src/content/docs/media-proxy/overview.md | 28 +- .../docs/media-proxy/responses-and-limits.md | 23 +- .../src/content/docs/media-proxy/routes.mdx | 30 +- .../docs/media-proxy/transformations.md | 18 +- .../content/docs/media-proxy/upload-relay.mdx | 82 +-- .../content/docs/operator/configuration.mdx | 203 +++---- .../src/content/docs/operator/get-started.mdx | 12 +- .../content/docs/operator/reverse-proxy.mdx | 78 +-- .../src/content/docs/operator/upgrading.mdx | 34 +- fluxer_docs/src/content/docs/snowflakes.md | 4 +- .../src/content/docs/topics/captcha.md | 24 +- .../src/content/docs/topics/locales.md | 16 +- .../src/content/docs/topics/rate-limits.md | 16 +- .../src/content/docs/topics/uploads.md | 72 +-- fluxer_docs/src/content/docs/voice/index.md | 12 +- fluxer_gateway/src/utils/backoff_utils.erl | 12 +- .../src/utils/custom_status_validation.erl | 16 +- fluxer_gateway/src/utils/user_utils.erl | 19 +- 111 files changed, 1616 insertions(+), 2430 deletions(-) create mode 100644 fluxer_docs/scripts/DocsRouteHeaders.ts delete mode 100644 fluxer_docs/src/content/docs/conventions.md diff --git a/fluxer_app/src/features/app/hooks/useTextOverflow.ts b/fluxer_app/src/features/app/hooks/useTextOverflow.ts index 80f7c6d36..a4932bd90 100644 --- a/fluxer_app/src/features/app/hooks/useTextOverflow.ts +++ b/fluxer_app/src/features/app/hooks/useTextOverflow.ts @@ -1,83 +1,54 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {observeResize} from '@app/features/platform/utils/SharedResizeObserver'; -import {useEffect, useState} from 'react'; - -type Callback = (el: Element) => void; - -function observeElement(el: Element, cb: Callback): () => void { - if (typeof ResizeObserver === 'undefined') return () => {}; - return observeResize(el, () => cb(el)); -} +import {type RefObject, useEffect, useState} from 'react'; type OverflowAxis = 'horizontal' | 'vertical' | 'both'; function getElementOverflowState(el: HTMLElement, axis: OverflowAxis): boolean { - const {scrollWidth, clientWidth, scrollHeight, clientHeight} = el; - const horizontalOverflowing = scrollWidth - clientWidth > 1; - const verticalOverflowing = scrollHeight - clientHeight > 1; - return axis === 'horizontal' - ? horizontalOverflowing - : axis === 'vertical' - ? verticalOverflowing - : horizontalOverflowing || verticalOverflowing; + return ( + (axis !== 'vertical' && el.scrollWidth - el.clientWidth > 1) || + (axis !== 'horizontal' && el.scrollHeight - el.clientHeight > 1) + ); } -export function useElementOverflow(element: HTMLElement | null, axis: OverflowAxis = 'horizontal'): boolean { +function useOverflow(target: HTMLElement | RefObject | null, axis: OverflowAxis): boolean { const [isOverflowing, setIsOverflowing] = useState(false); useEffect(() => { - if (!element) { + const element = target && 'current' in target ? target.current : target; + const ownerWindow = element?.ownerDocument.defaultView; + if (!element || !ownerWindow) { setIsOverflowing(false); return; } let rafId: number | null = null; + let disposed = false; const checkOverflow = () => { rafId = null; const overflowing = getElementOverflowState(element, axis); setIsOverflowing((prev) => (prev === overflowing ? prev : overflowing)); }; const scheduleCheckOverflow = () => { - if (rafId != null) return; - rafId = requestAnimationFrame(checkOverflow); + if (disposed || rafId != null) return; + rafId = ownerWindow.requestAnimationFrame(checkOverflow); }; scheduleCheckOverflow(); - const unobserve = observeElement(element, scheduleCheckOverflow); + const unobserve = typeof ResizeObserver === 'undefined' ? undefined : observeResize(element, scheduleCheckOverflow); return () => { + disposed = true; if (rafId != null) { - cancelAnimationFrame(rafId); + ownerWindow.cancelAnimationFrame(rafId); } - unobserve(); + unobserve?.(); }; - }, [element, axis]); + }, [target, axis]); return isOverflowing; } -export function useTextOverflow(ref: React.RefObject, axis: OverflowAxis = 'horizontal'): boolean { - const [isOverflowing, setIsOverflowing] = useState(false); - useEffect(() => { - const el = ref.current; - if (!el) { - setIsOverflowing(false); - return; - } - let rafId: number | null = null; - const checkOverflow = () => { - rafId = null; - const overflowing = getElementOverflowState(el, axis); - setIsOverflowing((prev) => (prev === overflowing ? prev : overflowing)); - }; - const scheduleCheckOverflow = () => { - if (rafId != null) return; - rafId = requestAnimationFrame(checkOverflow); - }; - scheduleCheckOverflow(); - const unobserve = observeElement(el, scheduleCheckOverflow); - return () => { - if (rafId != null) { - cancelAnimationFrame(rafId); - } - unobserve(); - }; - }, [ref, axis]); - return isOverflowing; +export function useElementOverflow(element: HTMLElement | null, axis: OverflowAxis = 'horizontal'): boolean { + return useOverflow(element, axis); +} + +export function useTextOverflow(ref: RefObject, axis: OverflowAxis = 'horizontal'): boolean { + return useOverflow(ref, axis); } diff --git a/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx b/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx index 17ce0b3e1..5feda4d88 100644 --- a/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx +++ b/fluxer_app/src/features/channel/components/CompactMemberCustomStatus.tsx @@ -9,7 +9,7 @@ import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import type {CustomStatus} from '@app/features/user/state/CustomStatus'; import {getCustomStatusText, isCustomStatusExpired, normalizeCustomStatus} from '@app/features/user/state/CustomStatus'; import clsx from 'clsx'; -import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; +import {type ReactNode, useEffect, useRef, useState} from 'react'; interface CompactMemberCustomStatusProps { className?: string; @@ -97,13 +97,7 @@ export function CompactMemberCustomStatus({ const timer = window.setTimeout(() => setExpiryTick((tick) => tick + 1), delay); return () => window.clearTimeout(timer); }, [status]); - const normalized = useMemo(() => { - const nextStatus = normalizeCustomStatus(status); - if (!nextStatus || isCustomStatusExpired(nextStatus)) { - return null; - } - return nextStatus; - }, [status]); + const normalized = normalizeCustomStatus(status); const fullText = normalized ? getCustomStatusText(normalized) : null; const isOverflowing = useTextOverflow(containerRef, {content: fullText, measureTextRange: true}); if (!normalized) { diff --git a/fluxer_app/src/features/connection/components/modals/AddConnectionModal.tsx b/fluxer_app/src/features/connection/components/modals/AddConnectionModal.tsx index f25439665..05de7a8e9 100644 --- a/fluxer_app/src/features/connection/components/modals/AddConnectionModal.tsx +++ b/fluxer_app/src/features/connection/components/modals/AddConnectionModal.tsx @@ -150,7 +150,7 @@ export const AddConnectionModal = observer(({defaultType}: AddConnectionModalPro if (type === ConnectionTypes.BLUESKY) { identifier = identifier.replace(/^https?:\/\/bsky\.app\/profile\//i, '').replace(/^@/, ''); } - if (UserConnection.hasConnectionByTypeAndName(type, identifier)) { + if (type === ConnectionTypes.DOMAIN && UserConnection.hasConnectionByTypeAndName(type, identifier)) { initiateForm.setError('identifier', { type: 'validate', message: i18n._(YOU_ALREADY_HAVE_THIS_CONNECTION_DESCRIPTOR), diff --git a/fluxer_app/src/features/navigation/utils/MobileNavigation.ts b/fluxer_app/src/features/navigation/utils/MobileNavigation.ts index 8d2b233e2..50ed3def1 100644 --- a/fluxer_app/src/features/navigation/utils/MobileNavigation.ts +++ b/fluxer_app/src/features/navigation/utils/MobileNavigation.ts @@ -37,17 +37,17 @@ const defaultNavigator: Navigator = { }; let inProgress = false; -let pendingTarget: string | null = null; function computeBasePath(url: string): string | null { const pathname = new URL(url, window.location.origin).pathname; if (Routes.isDMRoute(pathname) && pathname !== Routes.ME) { return Routes.ME; } - if (Routes.isGuildChannelRoute(pathname) && pathname.split('/').length === 4) { + if (Routes.isGuildChannelRoute(pathname)) { const parts = pathname.split('/'); - const guildId = parts[2]; - return Routes.guildChannel(guildId); + if (parts.length === 4) { + return Routes.guildChannel(parts[2]); + } } return null; } @@ -55,11 +55,10 @@ function computeBasePath(url: string): string | null { export function navigateToWithMobileHistory(url: string, isMobile: boolean, nav: Navigator = defaultNavigator): void { if (!isMobile) { inProgress = false; - pendingTarget = null; nav.replace(url); return; } - if (inProgress && (pendingTarget === url || pendingTarget !== null)) { + if (inProgress) { return; } const current = nav.getPath(); @@ -74,22 +73,11 @@ export function navigateToWithMobileHistory(url: string, isMobile: boolean, nav: nav.replace(url); return; } - if (current === base) { - inProgress = true; - pendingTarget = url; - nav.replaceSilent(base); - nav.push(url); - inProgress = false; - pendingTarget = null; - return; - } inProgress = true; - pendingTarget = url; try { nav.replaceSilent(base); nav.push(url); } finally { inProgress = false; - pendingTarget = null; } } diff --git a/fluxer_app/src/features/platform/utils/SharedResizeObserver.ts b/fluxer_app/src/features/platform/utils/SharedResizeObserver.ts index c0de4f155..0f7cdcb44 100644 --- a/fluxer_app/src/features/platform/utils/SharedResizeObserver.ts +++ b/fluxer_app/src/features/platform/utils/SharedResizeObserver.ts @@ -1,54 +1,68 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -type Callback = (entry: ResizeObserverEntry) => void; +type ResizeCallback = (entry: ResizeObserverEntry) => void; -const callbacks = new WeakMap>(); +const callbacks = new Map>(); let nativeObserver: ResizeObserver | null = null; -let observedElementCount = 0; function getObserver(): ResizeObserver { if (nativeObserver) return nativeObserver; - nativeObserver = new ResizeObserver((entries) => { - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + if (nativeObserver !== observer) return; const handlers = callbacks.get(entry.target); if (!handlers) continue; - for (const cb of handlers) { + for (const callback of [...handlers.keys()]) { + if (callbacks.get(entry.target) !== handlers) break; + if (!handlers.has(callback)) continue; try { - cb(entry); + callback(entry); } catch (error) { console.error('SharedResizeObserver callback threw:', error); } } } }); - return nativeObserver; + nativeObserver = observer; + return observer; } -export function observeResize(element: Element, callback: Callback): () => void { +export function observeResize(element: Element, callback: ResizeCallback): () => void { + const observer = getObserver(); let handlers = callbacks.get(element); if (!handlers) { - handlers = new Set(); - callbacks.set(element, handlers); - observedElementCount++; - getObserver().observe(element); - } - handlers.add(callback); - return () => unobserveResize(element, callback); -} - -export function unobserveResize(element: Element, callback: Callback): void { - const handlers = callbacks.get(element); - if (!handlers) return; - handlers.delete(callback); - if (handlers.size === 0) { - callbacks.delete(element); - nativeObserver?.unobserve?.(element); - observedElementCount = Math.max(0, observedElementCount - 1); - if (observedElementCount === 0) { - nativeObserver?.disconnect(); - nativeObserver = null; + try { + observer.observe(element); + } catch (error) { + if (callbacks.size === 0) { + nativeObserver = null; + observer.disconnect(); + } + throw error; } + handlers = new Map(); + callbacks.set(element, handlers); } + handlers.set(callback, (handlers.get(callback) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const count = handlers.get(callback); + if (!count) throw new Error('Resize subscription has no active callback'); + if (count > 1) { + handlers.set(callback, count - 1); + return; + } + handlers.delete(callback); + if (handlers.size > 0) return; + callbacks.delete(element); + if (callbacks.size === 0) { + nativeObserver = null; + observer.disconnect(); + } else { + observer.unobserve(element); + } + }; } diff --git a/fluxer_app/src/features/ui/hooks/useTextOverflow.ts b/fluxer_app/src/features/ui/hooks/useTextOverflow.ts index 8ed4bb7ec..5de841814 100644 --- a/fluxer_app/src/features/ui/hooks/useTextOverflow.ts +++ b/fluxer_app/src/features/ui/hooks/useTextOverflow.ts @@ -11,12 +11,6 @@ interface UseTextOverflowOptions { const OVERFLOW_EPSILON_PX = 1; -type FontFaceSetLike = { - ready?: Promise; - addEventListener?: (type: 'loadingdone', listener: () => void) => void; - removeEventListener?: (type: 'loadingdone', listener: () => void) => void; -}; - function isMeaningfullyGreater(measuredSize: number, availableSize: number): boolean { return measuredSize - availableSize > OVERFLOW_EPSILON_PX; } @@ -94,7 +88,8 @@ export function useTextOverflow( const [isOverflowing, setIsOverflowing] = useState(false); useEffect(() => { const element = elementRef.current; - if (!element || !content) { + const ownerWindow = element?.ownerDocument.defaultView; + if (!element || !ownerWindow || !content) { setIsOverflowing(false); return; } @@ -112,7 +107,7 @@ export function useTextOverflow( updateOverflowing(true); return; } - if (measureTextRange && typeof document !== 'undefined') { + if (measureTextRange) { const availableWidth = getAvailableInlineSize(element); if (isMeaningfullyGreater(measureRangeInlineSize(element), availableWidth)) { updateOverflowing(true); @@ -128,14 +123,14 @@ export function useTextOverflow( if (disposed || frameId != null) { return; } - frameId = window.requestAnimationFrame(checkOverflow); + frameId = ownerWindow.requestAnimationFrame(checkOverflow); }; scheduleOverflowCheck(); const unobserveResize = typeof ResizeObserver !== 'undefined' ? observeResize(element, scheduleOverflowCheck) : undefined; let mutationObserver: MutationObserver | null = null; - if (typeof MutationObserver !== 'undefined') { - mutationObserver = new MutationObserver(scheduleOverflowCheck); + if (typeof ownerWindow.MutationObserver !== 'undefined') { + mutationObserver = new ownerWindow.MutationObserver(scheduleOverflowCheck); mutationObserver.observe(element, { attributes: true, attributeFilter: ['alt', 'class', 'src', 'style'], @@ -145,14 +140,13 @@ export function useTextOverflow( }); } element.addEventListener('load', scheduleOverflowCheck, true); - const fontSet = - typeof document !== 'undefined' ? (document as Document & {fonts?: FontFaceSetLike}).fonts : undefined; + const fontSet = element.ownerDocument.fonts; fontSet?.ready?.then(scheduleOverflowCheck); fontSet?.addEventListener?.('loadingdone', scheduleOverflowCheck); return () => { disposed = true; if (frameId != null) { - window.cancelAnimationFrame(frameId); + ownerWindow.cancelAnimationFrame(frameId); } unobserveResize?.(); mutationObserver?.disconnect(); diff --git a/fluxer_app/src/features/user/components/modals/tabs/LanguageTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/LanguageTab.tsx index ae4b618c8..ef6018935 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/LanguageTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/LanguageTab.tsx @@ -26,6 +26,7 @@ import UserSettings from '@app/features/user/state/UserSettings'; import * as LocaleUtils from '@app/features/user/utils/LocaleUtils'; import {TimeFormatTypes} from '@fluxer/constants/src/UserConstants'; import {getFormattedTime} from '@fluxer/date_utils/src/DateFormatting'; +import {localeUses12Hour} from '@fluxer/date_utils/src/DateHourCycle'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; @@ -293,29 +294,6 @@ const LanguageTab = observer(() => { const appLocale = UserSettings.getLocale(); const browserLocale = navigator.language; const effectiveLocale = Accessibility.useBrowserLocaleForTimeFormat ? browserLocale : appLocale; - const localeUses12Hour = (locale: string): boolean => { - const lang = locale.toLowerCase(); - const twelveHourLocales = [ - 'en-us', - 'en-ca', - 'en-au', - 'en-nz', - 'en-ph', - 'en-in', - 'en-pk', - 'en-bd', - 'en-za', - 'es-mx', - 'es-co', - 'ar', - 'hi', - 'bn', - 'ur', - 'fil', - 'tl', - ]; - return twelveHourLocales.some((l) => lang.startsWith(l)); - }; const uses12Hour = localeUses12Hour(effectiveLocale); const sampleDate = new Date(2025, 0, 1, 14, 30, 0); const format = getFormattedTime(sampleDate, effectiveLocale, uses12Hour); diff --git a/fluxer_docs/astro.config.ts b/fluxer_docs/astro.config.ts index 7ce3672b1..44c650c06 100644 --- a/fluxer_docs/astro.config.ts +++ b/fluxer_docs/astro.config.ts @@ -157,7 +157,7 @@ export default defineConfig({ sidebar: [ { label: 'Reference', - items: [{label: 'Introduction', link: '/'}, 'authentication', 'snowflakes', 'conventions'], + items: [{label: 'Introduction', link: '/'}, 'authentication', 'snowflakes'], }, { label: 'Self-hosting', diff --git a/fluxer_docs/package.json b/fluxer_docs/package.json index 484ab499b..fcafa62a8 100644 --- a/fluxer_docs/package.json +++ b/fluxer_docs/package.json @@ -11,6 +11,7 @@ "typecheck": "astro check --minimumFailingSeverity warning", "verify": "pnpm verify:sidebar && pnpm verify:coverage && pnpm verify:schemas && pnpm verify:style", "verify:coverage": "tsx scripts/VerifyDocsCoverage.ts", + "verify:coverage:source": "tsx scripts/VerifyDocsCoverage.ts --source-only", "verify:schemas": "tsx scripts/VerifyDocsSchemas.ts", "verify:sidebar": "tsx scripts/VerifyDocsSidebar.ts", "verify:style": "tsx scripts/VerifyDocsStyle.ts" @@ -29,10 +30,14 @@ "devDependencies": { "@astrojs/check": "catalog:", "@fluxer/openapi": "workspace:*", + "@mdx-js/mdx": "catalog:", "@types/hast": "catalog:", + "@types/mdast": "catalog:", "@types/node": "catalog:", "@types/send": "catalog:", + "mdast-util-mdx-jsx": "catalog:", "mdast-util-to-hast": "catalog:", + "remark-gfm": "catalog:", "typescript": "6.0.2" } } diff --git a/fluxer_docs/scripts/DocsRouteHeaders.ts b/fluxer_docs/scripts/DocsRouteHeaders.ts new file mode 100644 index 000000000..7ad1ecbe3 --- /dev/null +++ b/fluxer_docs/scripts/DocsRouteHeaders.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import {parseFrontmatter} from '@astrojs/markdown-remark'; +import {createProcessor} from '@mdx-js/mdx'; +import type {Nodes, Root} from 'mdast'; +import type {MdxJsxAttribute, MdxJsxFlowElement, MdxJsxTextElement} from 'mdast-util-mdx-jsx'; +import remarkGfm from 'remark-gfm'; +import {HTTP_METHODS, type MarkdownPage} from './DocsSource.ts'; + +export interface DocsRouteHeader { + readonly method: string; + readonly path: string; + readonly file: string; + readonly bot: boolean; + readonly unauthenticated: boolean; + readonly line: number; + readonly endLine: number; +} + +const STRING_ATTRIBUTES = new Set(['method', 'path', 'oauth2']); +const BOOLEAN_ATTRIBUTES = new Set(['bot', 'unauthenticated', 'auditReason', 'mfa']); +const markdownProcessor = createProcessor({format: 'md', remarkPlugins: [remarkGfm]}); +const mdxProcessor = createProcessor({format: 'mdx', remarkPlugins: [remarkGfm]}); + +function readAttributeValue(attribute: MdxJsxAttribute, location: string): string | boolean { + const value = attribute.value; + if (value === null || value === undefined) return true; + if (typeof value === 'string') return value; + const body = value.data?.estree?.body; + if (body?.length === 1 && body[0].type === 'ExpressionStatement') { + const expression = body[0].expression; + if ( + expression.type === 'Literal' && + (typeof expression.value === 'string' || typeof expression.value === 'boolean') + ) { + return expression.value; + } + if ( + expression.type === 'TemplateLiteral' && + expression.expressions.length === 0 && + expression.quasis.length === 1 && + typeof expression.quasis[0].value.cooked === 'string' + ) { + return expression.quasis[0].value.cooked; + } + } + throw new Error(`${location}: RouteHeader ${attribute.name} requires a literal string or boolean`); +} + +function readHeader(node: MdxJsxFlowElement | MdxJsxTextElement, file: string): DocsRouteHeader { + assert(node.position !== undefined, 'Parsed RouteHeader has no source position'); + const line = node.position.start.line; + const endLine = node.position.end.line; + assert(Number.isInteger(line) && line > 0 && Number.isInteger(endLine) && endLine >= line); + const location = `${file}:${line}`; + const attributes = new Map(); + for (const attribute of node.attributes) { + if (attribute.type === 'mdxJsxExpressionAttribute') { + throw new Error(`${location}: RouteHeader spread attributes are unsupported`); + } + const name = attribute.name; + if (attributes.has(name)) { + throw new Error(`${location}: duplicate RouteHeader attribute ${name}`); + } + const isString = STRING_ATTRIBUTES.has(name); + if (!isString && !BOOLEAN_ATTRIBUTES.has(name)) { + throw new Error(`${location}: unknown RouteHeader attribute ${name}`); + } + const value = readAttributeValue(attribute, location); + if (isString ? typeof value !== 'string' : typeof value !== 'boolean') { + throw new Error(`${location}: RouteHeader ${name} must be ${isString ? 'a string' : 'a boolean'}`); + } + attributes.set(name, value); + } + const method = attributes.get('method'); + const routePath = attributes.get('path'); + if (typeof method !== 'string' || typeof routePath !== 'string') { + throw new Error(`${location}: RouteHeader requires method and path attributes`); + } + if (!HTTP_METHODS.has(method)) { + throw new Error(`${location}: unsupported RouteHeader method ${method}`); + } + if (!routePath.startsWith('/')) { + throw new Error(`${location}: RouteHeader path must be absolute`); + } + return { + method, + path: routePath, + file, + bot: attributes.get('bot') === true, + unauthenticated: attributes.get('unauthenticated') === true, + line, + endLine, + }; +} + +export function readRouteHeaders(page: MarkdownPage): Array { + let tree: Root; + try { + const {content} = parseFrontmatter(page.source, {frontmatter: 'empty-with-spaces'}); + const processor = page.file.endsWith('.mdx') ? mdxProcessor : markdownProcessor; + tree = processor.parse({value: content, path: page.file}); + } catch (error) { + throw new Error(`${page.relativePath}: ${String(error)}`, {cause: error}); + } + const headers: Array = []; + const pending: Array = [tree]; + while (pending.length > 0) { + const node = pending.pop(); + assert(node !== undefined); + if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') && node.name === 'RouteHeader') { + headers.push(readHeader(node, page.relativePath)); + } + if ('children' in node) { + for (let index = node.children.length - 1; index >= 0; index -= 1) { + pending.push(node.children[index]); + } + } + } + return headers; +} diff --git a/fluxer_docs/scripts/DocsSource.ts b/fluxer_docs/scripts/DocsSource.ts index ac5fe91d5..8cb867173 100644 --- a/fluxer_docs/scripts/DocsSource.ts +++ b/fluxer_docs/scripts/DocsSource.ts @@ -1,8 +1,16 @@ -import {readdir} from 'node:fs/promises'; +import {readdir, readFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; export const DOCS_ROOT = fileURLToPath(new URL('../src/content/docs/', import.meta.url)); +export const HTTP_METHODS: ReadonlySet = new Set(['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS']); + +export interface MarkdownPage { + readonly file: string; + readonly relativePath: string; + readonly source: string; + readonly lines: ReadonlyArray; +} export async function listMarkdownFiles(directory: string): Promise> { const files: Array = []; @@ -13,11 +21,49 @@ export async function listMarkdownFiles(directory: string): Promise> { + const pages: Array = []; + for (const file of await listMarkdownFiles(directory)) { + const source = await readFile(file, 'utf8'); + pages.push({ + file, + relativePath: path.relative(directory, file).split(path.sep).join('/'), + source, + lines: source.split('\n'), + }); + } + return pages; +} + +export function splitTableRow(line: string): Array { + const row = line.trim(); + const cells: Array = []; + let start = row.startsWith('|') ? 1 : 0; + for (let index = start; index < row.length; index += 1) { + if (row[index] === '\\' && (row[index + 1] === '\\' || row[index + 1] === '|')) { + index += 1; + continue; + } + if (row[index] === '|') { + cells.push(row.slice(start, index).trim()); + start = index + 1; + } + } + if (start < row.length) { + cells.push(row.slice(start).trim()); + } + return cells; +} + +export function routeShape(method: string, routePath: string): string { + return `${method} ${routePath.split('?')[0].replace(/\{[^}]*\}/gu, '{}')}`; } export function slugifyHeading(heading: string): string { diff --git a/fluxer_docs/scripts/DocsTableWidth.ts b/fluxer_docs/scripts/DocsTableWidth.ts index 4ba8beb7e..e6e886643 100644 --- a/fluxer_docs/scripts/DocsTableWidth.ts +++ b/fluxer_docs/scripts/DocsTableWidth.ts @@ -13,6 +13,7 @@ import { TABLE_MAX_IDENT_CHARS, TABLE_WIDE_TIER_PX, } from '../src/table/DocsTableMetrics.ts'; +import {splitTableRow} from './DocsSource.ts'; export { columnWidthPercents, @@ -53,34 +54,9 @@ export interface DocsTable { readonly nonParallelReason: string; } -function splitRow(line: string): Array { - const cells: Array = []; - let current = ''; - const body = line.trim().replace(/^\|/u, '').replace(/\|$/u, ''); - for (let index = 0; index < body.length; index += 1) { - const character = body[index]; - if (character === '\\' && index + 1 < body.length) { - current += body[index + 1]; - index += 1; - continue; - } - if (character === '|') { - cells.push(current); - current = ''; - continue; - } - current += character; - } - cells.push(current); - return cells.map((cell) => cell.trim()); -} - -function isDelimiter(line: string): boolean { - const trimmed = line.trim(); - if (!trimmed.startsWith('|')) { - return false; - } - return /^\|[\s:|-]+\|?$/u.test(trimmed) && trimmed.includes('-'); +function isDelimiter(line: string, columns: number): boolean { + const cells = splitTableRow(line); + return cells.length === columns && cells.every((cell) => /^:?-+:?$/u.test(cell)); } function stripInline(text: string): string { @@ -95,7 +71,8 @@ function stripInline(text: string): string { .replace(/ /gu, ' ') .replace(/</gu, '<') .replace(/>/gu, '>') - .replace(/&/gu, '&'); + .replace(/&/gu, '&') + .replace(/\\([!-/:-@[-`{-~])/gu, '$1'); } function pieces(cell: string): Array { @@ -107,7 +84,10 @@ function pieces(cell: string): Array { if (match.index > last) { out.push({text: stripInline(cell.slice(last, match.index)), code: false}); } - out.push({text: match[1], code: true}); + out.push({ + text: match[1].replace(/\\([\\|])/gu, (escaped, character: string) => (character === '|' ? character : escaped)), + code: true, + }); last = pattern.lastIndex; match = pattern.exec(cell); } @@ -127,7 +107,7 @@ function cellKind(cell: string): string { if (parts.length === 0) { return 'empty'; } - if (parts.every((piece) => piece.code)) { + if (parts.every((piece) => piece.code || /^[\s,]+$/u.test(piece.text))) { return 'code'; } if (parts.every((piece) => !piece.code)) { @@ -151,16 +131,20 @@ export function extractTables(source: string): Array { index += 1; continue; } - if (!lines[index].trim().startsWith('|') || index + 1 >= lines.length || !isDelimiter(lines[index + 1])) { + if (!lines[index].trim().startsWith('|') || index + 1 >= lines.length) { + index += 1; + continue; + } + const header = splitTableRow(lines[index]); + if (header.length === 0 || !isDelimiter(lines[index + 1], header.length)) { index += 1; continue; } - const header = splitRow(lines[index]); const start = index; index += 2; const body: Array> = []; while (index < lines.length && lines[index].trim().startsWith('|')) { - body.push(splitRow(lines[index])); + body.push(splitTableRow(lines[index])); index += 1; } const columns = header.length; diff --git a/fluxer_docs/scripts/VerifyDocsCoverage.ts b/fluxer_docs/scripts/VerifyDocsCoverage.ts index 1527f4611..2e63ef48b 100644 --- a/fluxer_docs/scripts/VerifyDocsCoverage.ts +++ b/fluxer_docs/scripts/VerifyDocsCoverage.ts @@ -7,20 +7,21 @@ import {mkdir, mkdtemp, readdir, readFile, rm, writeFile} from 'node:fs/promises import {tmpdir} from 'node:os'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {parseArgs} from 'node:util'; import {extractRoutesFromControllers} from '@fluxer/openapi/src/extractors/RouteExtractor'; +import type {OpenAPIDocument} from '@fluxer/openapi/src/OpenAPITypes'; import {installerChecksumLine} from '../src/installer/InstallerDigest.ts'; -import {DOCS_ROOT, listMarkdownFiles} from './DocsSource.ts'; +import {type DocsRouteHeader, readRouteHeaders} from './DocsRouteHeaders.ts'; +import {DOCS_ROOT, HTTP_METHODS, type MarkdownPage, readMarkdownPages, routeShape} from './DocsSource.ts'; + +const {values: options} = parseArgs({options: {'source-only': {type: 'boolean', default: false}}}); const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json'); const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json'); const MEDIA_PROXY_SERVER_DIR = path.join(REPO_ROOT, 'fluxer_media_proxy/src/server'); -const ROUTE_HEADER_PATTERN = /]*?)\/>/gu; -const ATTRIBUTE_PATTERN = /(\w+)\s*=\s*"([^"]*)"/gu; -const HTTP_METHODS = new Set(['GET', 'HEAD', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS']); - const BLUESKY_OAUTH_CONTROLLER = 'fluxer_api/src/api/bluesky/BlueskyOAuthController.ts'; const DOWNLOAD_CONTROLLER = 'fluxer_api/src/api/download/DownloadController.ts'; @@ -222,15 +223,6 @@ const MEDIA_PROXY_ASSET_PREFIXES = new Map([ ['guilds', 'fn parse_guild_member_asset_path'], ]); -interface DocumentedRoute { - readonly method: string; - readonly path: string; - readonly file: string; - readonly bot: boolean; - readonly unauthenticated: boolean; - readonly oauth2: string | null; -} - interface SpecOperation { readonly method: string; readonly path: string; @@ -238,6 +230,11 @@ interface SpecOperation { readonly security: ReadonlyArray>> | null; } +interface EffectiveSpecSecurity { + readonly schemes: ReadonlySet; + readonly allowsAnonymous: boolean; +} + function stripVersionPrefix(routePath: string): string { if (routePath === '/v1') { return '/'; @@ -248,62 +245,35 @@ function stripVersionPrefix(routePath: string): string { return routePath; } -function shapeOf(method: string, routePath: string): string { - const withoutQuery = routePath.split('?')[0]; - return `${method} ${withoutQuery.replace(/\{[^}]*\}/gu, '{}')}`; -} - -async function documentedRoutes(): Promise> { - const files = await listMarkdownFiles(DOCS_ROOT); - const routes: Array = []; - for (const file of files) { - const source = await readFile(file, 'utf8'); - for (const match of source.matchAll(ROUTE_HEADER_PATTERN)) { - const attributes = new Map(); - for (const attribute of match[1].matchAll(ATTRIBUTE_PATTERN)) { - attributes.set(attribute[1], attribute[2]); - } - const method = attributes.get('method'); - const routePath = attributes.get('path'); - if (method == null || routePath == null) { - continue; - } - const bareFlags = new Set( - match[1] - .replace(/\w+\s*=\s*"[^"]*"/gu, ' ') - .split(/\s+/u) - .filter((token) => token.length > 0), +function indexDocumentedRoutes(routes: ReadonlyArray): Map { + const index = new Map(); + for (const route of routes) { + const key = routeShape(route.method, stripVersionPrefix(route.path)); + const existing = index.get(key); + if (existing !== undefined) { + throw new Error( + `Duplicate documented route ${key}: ${existing.file}:${existing.line} and ${route.file}:${route.line}`, ); - routes.push({ - method, - path: routePath, - file: path.relative(DOCS_ROOT, file), - bot: bareFlags.has('bot'), - unauthenticated: bareFlags.has('unauthenticated'), - oauth2: attributes.get('oauth2') ?? null, - }); } + index.set(key, route); } - return routes; + return index; } interface AliasRoute { readonly shape: string; readonly file: string; - readonly successor: string; } const ALIAS_TABLE_HEADER = '| Method | Deprecated path | Successor |'; const ALIAS_ROW_PATTERN = - /^\|\s*(GET|HEAD|POST|PATCH|PUT|DELETE|OPTIONS)\s*\|\s*`(\/[^`]+)`\s*\|\s*\[([^\]]+)\]\([^)]+\)\s*\|\s*$/u; + /^\|\s*(GET|HEAD|POST|PATCH|PUT|DELETE|OPTIONS)\s*\|\s*`(\/[^`]+)`\s*\|\s*\[[^\]]+\]\([^)]+\)\s*\|\s*$/u; -async function aliasDocumentedRoutes(): Promise> { - const files = await listMarkdownFiles(DOCS_ROOT); +function aliasDocumentedRoutes(pages: ReadonlyArray): Array { const aliases: Array = []; - for (const file of files) { - const source = await readFile(file, 'utf8'); + for (const {relativePath: file, lines} of pages) { let inTable = false; - for (const line of source.split('\n')) { + for (const line of lines) { if (line.trim() === ALIAS_TABLE_HEADER) { inTable = true; continue; @@ -317,9 +287,8 @@ async function aliasDocumentedRoutes(): Promise> { continue; } aliases.push({ - shape: shapeOf(row[1], stripVersionPrefix(row[2])), - file: path.relative(DOCS_ROOT, file), - successor: row[3], + shape: routeShape(row[1], stripVersionPrefix(row[2])), + file, }); } } @@ -331,13 +300,9 @@ async function specOperations(specPath: string): Promise> { if (typeof spec !== 'object' || spec == null || !('paths' in spec)) { throw new Error(`Spec has no paths: ${specPath}`); } - const paths = ( - spec as { - paths: Record; security?: Array>>}>>; - } - ).paths; + const document = spec as OpenAPIDocument; const operations: Array = []; - for (const [routePath, item] of Object.entries(paths)) { + for (const [routePath, item] of Object.entries(document.paths)) { for (const [method, operation] of Object.entries(item)) { const upper = method.toUpperCase(); if (!HTTP_METHODS.has(upper)) { @@ -347,7 +312,7 @@ async function specOperations(specPath: string): Promise> { method: upper, path: stripVersionPrefix(routePath), tags: operation.tags ?? [], - security: operation.security ?? null, + security: operation.security === undefined ? (document.security ?? null) : operation.security, }); } } @@ -404,18 +369,19 @@ const astRoute = (route: {method: string; path: string}): string => { `{${name}}${constraint == null ? '' : constraintTail(constraint)}`, ) .replace(/\*/gu, '{wildcard}'); - return shapeOf(route.method.toUpperCase(), stripVersionPrefix(templated)); + return routeShape(route.method.toUpperCase(), stripVersionPrefix(templated)); }; -const documented = await documentedRoutes(); -const aliasDocumented = await aliasDocumentedRoutes(); -const aliasShapes = new Map(aliasDocumented.map((alias) => [alias.shape, alias])); -const documentedFlags = new Map(); -for (const route of documented) { - documentedFlags.set(shapeOf(route.method, stripVersionPrefix(route.path)), { - bot: route.bot, - unauthenticated: route.unauthenticated, - }); +const pages = await readMarkdownPages(DOCS_ROOT); +const documented = pages.flatMap(readRouteHeaders); +const aliasDocumented = aliasDocumentedRoutes(pages); +const aliasShapes = new Map(); +for (const alias of aliasDocumented) { + const existing = aliasShapes.get(alias.shape); + if (existing !== undefined) { + throw new Error(`Duplicate deprecated alias ${alias.shape}: ${existing.file} and ${alias.file}`); + } + aliasShapes.set(alias.shape, alias); } const mediaProxySource = await (async () => { const sources: Array = []; @@ -484,17 +450,11 @@ const mainDocumented = documented.filter( const main = await specOperations(MAIN_SPEC); const admin = await specOperations(ADMIN_SPEC); -const mainShapes = new Set(main.map((operation) => shapeOf(operation.method, operation.path))); -const adminShapes = new Set(admin.map((operation) => shapeOf(operation.method, operation.path))); +const mainShapes = new Set(main.map((operation) => routeShape(operation.method, operation.path))); +const adminShapes = new Set(admin.map((operation) => routeShape(operation.method, operation.path))); -const documentedMain = new Map(); -for (const route of mainDocumented) { - documentedMain.set(shapeOf(route.method, stripVersionPrefix(route.path)), route); -} -const documentedAdmin = new Map(); -for (const route of adminDocumented) { - documentedAdmin.set(shapeOf(route.method, stripVersionPrefix(route.path)), route); -} +const documentedMain = indexDocumentedRoutes(mainDocumented); +const documentedAdmin = indexDocumentedRoutes(adminDocumented); const registered = new Map(); for (const route of controllerRoutes) { @@ -572,7 +532,7 @@ failures += section( 'present in the live spec but undocumented', main .filter((operation) => { - const shape = shapeOf(operation.method, operation.path); + const shape = routeShape(operation.method, operation.path); if (documentedMain.has(shape)) { return false; } @@ -598,7 +558,7 @@ failures += section( .map((shape) => `${shape} is an alias row and a RouteHeader, which double counts it`) .sort(), ); -console.log(` documented as a deprecated alias of a documented route: ${aliasShapes.size.toString()}`); +console.log(` documented in deprecated alias tables: ${aliasShapes.size.toString()}`); const wronglyDocumented = [...DELIBERATELY_UNDOCUMENTED.entries()] .filter(([shape]) => documentedMain.has(shape)) .map(([shape, reason]) => `${shape} must not be documented: ${reason}`); @@ -616,7 +576,7 @@ for (const [shape, {reason}] of MAIN_SPEC_EXEMPT) { console.log('media proxy'); const mediaProxyProblems: Array = []; for (const route of mediaProxyDocumented) { - const shape = shapeOf(route.method, route.path); + const shape = routeShape(route.method, route.path); if (MEDIA_PROXY_ROUTES.has(shape)) { continue; } @@ -781,9 +741,7 @@ console.log('enum names and error codes'); const enumRows: Array<{file: string; line: number; name: string}> = []; const codeRows: Array<{file: string; line: number; code: string}> = []; - for (const file of await listMarkdownFiles(DOCS_ROOT)) { - const relative = path.relative(DOCS_ROOT, file); - const lines = (await readFile(file, 'utf8')).split('\n'); + for (const {relativePath: relative, lines} of pages) { let inEnumTable = false; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]; @@ -963,9 +921,7 @@ console.log('rate limit buckets, limits and windows'); /([\d,]+) requests? per ([a-z0-9 ]+?)(?:,| for [^.]*?,) on the (?:shared )?`([a-z0-9_:@{}]+)` bucket/gu; const problems: Array = []; let claims = 0; - for (const file of await listMarkdownFiles(DOCS_ROOT)) { - const relative = path.relative(DOCS_ROOT, file); - const lines = (await readFile(file, 'utf8')).split('\n'); + for (const {relativePath: relative, lines} of pages) { for (let i = 0; i < lines.length; i += 1) { for (const claim of lines[i].matchAll(claimPattern)) { claims += 1; @@ -1328,15 +1284,14 @@ console.log('self-hosting guide against deploy/self-hosting'); const PIPE_TO_SHELL = /(?:curl|wget|iwr|Invoke-WebRequest)[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|iex|Invoke-Expression)\b/iu; - const docsPages = await listMarkdownFiles(DOCS_ROOT); for (const [name, source] of installers) { if (PIPE_TO_SHELL.test(source)) { problems.push(`${name} pipes a download into a shell`); } } - for (const page of docsPages) { - if (PIPE_TO_SHELL.test(await readFile(page, 'utf8'))) { - problems.push(`${path.relative(DOCS_ROOT, page)} pipes a download into a shell`); + for (const {relativePath, source} of pages) { + if (PIPE_TO_SHELL.test(source)) { + problems.push(`${relativePath} pipes a download into a shell`); } } @@ -1375,10 +1330,9 @@ console.log('self-hosting guide against deploy/self-hosting'); 'src/components/InstallerChecksum.astro', await readFile(fileURLToPath(new URL('../src/components/InstallerChecksum.astro', import.meta.url)), 'utf8'), ]); - for (const page of docsPages) { - const text = await readFile(page, 'utf8'); - if (text.includes('install.sh') || text.includes('install.ps1')) { - digestBearing.push([path.relative(DOCS_ROOT, page), text]); + for (const {relativePath, source} of pages) { + if (source.includes('install.sh') || source.includes('install.ps1')) { + digestBearing.push([relativePath, source]); } } for (const [where, text] of digestBearing) { @@ -1389,148 +1343,10 @@ console.log('self-hosting guide against deploy/self-hosting'); } } - const shellParse = spawnSync('sh', ['-n', path.join(INSTALLER_ROOT, 'install.sh')], {encoding: 'utf8'}); - if (shellParse.error != null) { - problems.push(`sh -n could not run against install.sh: ${shellParse.error.message}`); - } else if (shellParse.status !== 0) { - problems.push(`sh -n rejects install.sh: ${shellParse.stderr.trim()}`); - } - - const DOCKER_STUB = [ - '#!/bin/sh', - 'case "$1 $2" in', - " '--version ') echo 'Docker version 27.1.1, build stub' ;;", - " 'compose version') if [ \"$3\" = '--short' ]; then echo '2.30.3'; else echo 'v2.30.3'; fi ;;", - " 'compose config') echo 'ghcr.io/fluxerapp/fluxer-api:v1' ;;", - 'esac', - 'exit 0', - '', - ].join('\n'); - const CURL_STUB = [ - '#!/bin/sh', - "out=''", - "prev=''", - 'for arg in "$@"; do', - ' if [ "$prev" = \'-o\' ]; then out=$arg; fi', - ' prev=$arg', - 'done', - '[ -z "$out" ] || printf \'name: fluxer\\nservices:\\n api:\\n image: stub\\n\' > "$out"', - '', - ].join('\n'); - - const sandbox = await mkdtemp(path.join(tmpdir(), 'fluxer-installer-')); - try { - const stubBin = path.join(sandbox, 'bin'); - await mkdir(stubBin, {recursive: true}); - await writeFile(path.join(stubBin, 'docker'), DOCKER_STUB, {mode: 0o755}); - await writeFile(path.join(stubBin, 'curl'), CURL_STUB, {mode: 0o755}); - await writeFile(path.join(stubBin, 'openssl'), '#!/bin/sh\nexit 0\n', {mode: 0o755}); - - const instance = path.join(sandbox, 'instance'); - await mkdir(instance, {recursive: true}); - await writeFile(path.join(instance, '.env'), 'FLUXER_DOMAIN=x.example\nFLUXER_IMAGE_TAG=2026.813.205040\n'); - await writeFile(path.join(instance, 'docker-compose.yml'), 'name: fluxer\nservices:\n api:\n image: stub\n'); - - const planned = (label: string, args: ReadonlyArray, cwd?: string): string | null => { - const run = spawnSync('sh', [path.join(INSTALLER_ROOT, 'install.sh'), ...args], { - cwd, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ''}`, - ...(cwd == null ? {} : {PWD: cwd}), - }, - }); - if (run.error != null) { - problems.push(`install.sh ${label} could not run: ${run.error.message}`); - return null; - } - if (run.status !== 0) { - problems.push(`install.sh ${label} exited ${String(run.status)}: ${run.stderr.trim()}`); - return null; - } - return run.stdout; - }; - - const plannedRef = (label: string, args: ReadonlyArray): string | null => { - const stdout = planned(label, args); - if (stdout == null) { - return null; - } - const line = stdout.match(/^ {2}ref\s+(\S+)$/mu); - if (line == null) { - problems.push(`install.sh ${label} printed no ref line`); - return null; - } - return line[1]; - }; - - const INSTALL_ARGS = [ - '--dry-run', - '--non-interactive', - '--allow-root', - '--domain', - 'x.example', - '--email', - 'a@x.example', - '--dir', - path.join(sandbox, 'target'), - ]; - const REF_CASES: ReadonlyArray, string]> = [ - ['on the default image tag', INSTALL_ARGS, 'main'], - ['under --image-tag latest', [...INSTALL_ARGS, '--image-tag', 'latest'], 'main'], - ['under --image-tag 2026.813.205040', [...INSTALL_ARGS, '--image-tag', '2026.813.205040'], '2026.813.205040'], - [ - 'under --ref feature/x --image-tag 2026.813.205040', - [...INSTALL_ARGS, '--ref', 'feature/x', '--image-tag', '2026.813.205040'], - 'feature/x', - ], - [ - 'under --update against a pinned .env', - ['--update', '--dry-run', '--allow-root', '--dir', instance], - '2026.813.205040', - ], - ]; - for (const [label, args, expected] of REF_CASES) { - const resolved = plannedRef(label, args); - if (resolved != null && resolved !== expected) { - problems.push(`install.sh ${label} plans ref ${resolved}, and the image tag it pairs with wants ${expected}`); - } - } - - const composeYmlInstance = path.join(sandbox, 'compose-yml-instance'); - await mkdir(composeYmlInstance, {recursive: true}); - await writeFile( - path.join(composeYmlInstance, '.env'), - 'FLUXER_DOMAIN=x.example\nFLUXER_IMAGE_TAG=2026.813.205040\n', - ); - await writeFile(path.join(composeYmlInstance, 'compose.yml'), 'name: fluxer\nservices:\n api:\n image: stub\n'); - const COMPOSE_NAME_CASES: ReadonlyArray, string | undefined]> = [ - [ - 'under --update against a compose.yml instance', - ['--update', '--dry-run', '--allow-root', '--dir', composeYmlInstance], - undefined, - ], - [ - 'under --update standing in a compose.yml instance', - ['--update', '--dry-run', '--allow-root'], - composeYmlInstance, - ], - ]; - for (const [label, args, cwd] of COMPOSE_NAME_CASES) { - const stdout = planned(label, args, cwd); - if (stdout == null) { - continue; - } - if (!/^ {4}compose\.yml is unchanged$/mu.test(stdout)) { - problems.push(`install.sh ${label} does not plan the refreshed stack file onto compose.yml`); - } - if (/^ {4}docker-compose\.yml is new$/mu.test(stdout)) { - problems.push(`install.sh ${label} plans a docker-compose.yml that Compose would never load there`); - } - } - } finally { - await rm(sandbox, {recursive: true, force: true}); + if (options['source-only']) { + console.log(' installer execution skipped (--source-only)'); + } else { + problems.push(...(await verifyInstallerExecution(INSTALLER_ROOT))); } const covered = new Set( @@ -1791,10 +1607,10 @@ console.log('unthrottled routes and global bucket claims'); admin: route.path.startsWith('/admin'), })); - const publicShapes = new Set(main.map((operation) => shapeOf(operation.method, operation.path))); + const publicShapes = new Set(main.map((operation) => routeShape(operation.method, operation.path))); const publicUnthrottled = unthrottled .filter((entry) => !entry.admin) - .map((entry) => shapeOf(entry.method, stripVersionPrefix(entry.route.replace(/:([a-zA-Z_]+)/gu, '{$1}')))) + .map((entry) => routeShape(entry.method, stripVersionPrefix(entry.route.replace(/:([a-zA-Z_]+)/gu, '{$1}')))) .filter((shape) => publicShapes.has(shape)); const adminUnthrottled = unthrottled.filter((entry) => entry.admin); @@ -1868,6 +1684,14 @@ console.log('error registry and abuse signal weights'); for (const entry of registrySource.matchAll(/^\t([A-Z][A-Z0-9_]*):/gmu)) { registryCodes.add(entry[1]); } + const validationSource = await readFile( + path.join(REPO_ROOT, 'packages/constants/src/ValidationErrorCodes.ts'), + 'utf8', + ); + const validationCodes = new Set(); + for (const entry of validationSource.matchAll(/^\t([A-Z][A-Z0-9_]*):/gmu)) { + validationCodes.add(entry[1]); + } const problems: Array = []; for (const code of registryCodes) { if (!documentedCodes.has(code)) { @@ -1890,11 +1714,18 @@ console.log('error registry and abuse signal weights'); } const documentedRegistryCodes = [...registryCodes].filter((c) => documentedCodes.has(c)).length; - if (documentedEntries < 491) { + const UNDOCUMENTED_VALIDATION_CODES = new Set(['EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL']); + const expectedEntries = registryCodes.size + validationCodes.size - UNDOCUMENTED_VALIDATION_CODES.size; + if (documentedEntries < expectedEntries) { problems.push( - `errors.md code entries parsed fell to ${documentedEntries.toString()}, floor is 491, the 255 API codes plus the 236 validation codes. The registry parser reads a \`| CODE |\` table row and a \`### \`CODE\`\` or \`#### \`CODE\`\` heading, and one of those shapes has stopped matching`, + `errors.md code entries parsed fell to ${documentedEntries.toString()}, expected ${expectedEntries.toString()}, the ${registryCodes.size.toString()} API codes plus the ${validationCodes.size.toString()} validation codes less the ${UNDOCUMENTED_VALIDATION_CODES.size.toString()} named as undocumented. Either a code lost its entry, or the registry parser stopped matching one of the \`| CODE |\` table row and \`### \`CODE\`\` or \`#### \`CODE\`\` heading shapes it reads`, ); } + for (const code of documentedCodes) { + if (!registryCodes.has(code) && !validationCodes.has(code)) { + problems.push(`errors.md documents ${code}, which is in neither code registry`); + } + } if (documentedRegistryCodes < registryCodes.size) { problems.push( `errors.md documents ${documentedRegistryCodes.toString()} of the ${registryCodes.size.toString()} registry codes, floor is every one of them`, @@ -1985,7 +1816,7 @@ console.log('snowflake layout'); if (!page.includes(String(epoch))) { problems.push(`snowflakes.md does not state the epoch ${epoch.toString()}`); } - const epochIso = new Date(epoch).toISOString().replace('.000Z', '.000Z'); + const epochIso = new Date(epoch).toISOString(); if (!page.includes(epochIso.slice(0, 10))) { problems.push(`snowflakes.md does not state the epoch date ${epochIso}`); } @@ -1997,7 +1828,7 @@ console.log('snowflake layout'); } const timestampBits = 63 - shift; const lastMs = epoch + 2 ** timestampBits - 1; - const lastIso = new Date(lastMs).toISOString().replace('Z', 'Z'); + const lastIso = new Date(lastMs).toISOString(); if (!page.includes(lastIso.slice(0, 19))) { problems.push(`snowflakes.md does not state the last representable instant ${lastIso}`); } @@ -2012,7 +1843,6 @@ console.log('snowflake layout'); console.log('attachment upload geometry'); { const limits = await readFile(path.join(REPO_ROOT, 'packages/constants/src/LimitConstants.ts'), 'utf8'); - const page = await readFile(path.join(DOCS_ROOT, 'topics/uploads.md'), 'utf8'); const problems: Array = []; const readConst = (name: string): number | null => { const found = limits.match(new RegExp(`${name} = ([^;]+);`, 'u')); @@ -2029,22 +1859,16 @@ console.log('attachment upload geometry'); .reduce((a, b) => a + b, 0); }; - const constants: Array<[string, string]> = [ - ['ATTACHMENT_UPLOAD_CHUNK_THRESHOLD', 'singlepart threshold'], - ['ATTACHMENT_UPLOAD_MIN_CHUNK_SIZE', 'minimum part size'], - ['ATTACHMENT_UPLOAD_MAX_CHUNKS', 'maximum part count'], - ['ATTACHMENT_MAX_SIZE_NON_PREMIUM', 'non-premium attachment ceiling'], - ['ATTACHMENT_MAX_SIZE_PREMIUM', 'premium attachment ceiling'], - ['ATTACHMENT_MAX_SIZE_BOT', 'bot attachment ceiling'], + const constants: Array<[string, string, string]> = [ + ['ATTACHMENT_UPLOAD_CHUNK_THRESHOLD', 'singlepart threshold', 'topics/uploads.md'], + ['ATTACHMENT_MAX_SIZE_NON_PREMIUM', 'non-premium attachment ceiling', 'http-api/messages.mdx'], + ['ATTACHMENT_MAX_SIZE_PREMIUM', 'premium attachment ceiling', 'http-api/messages.mdx'], + ['ATTACHMENT_MAX_SIZE_BOT', 'bot attachment ceiling', 'http-api/messages.mdx'], ]; - const divisor = readConst('ATTACHMENT_UPLOAD_TARGET_PART_COUNT'); - if (divisor == null) { - problems.push('could not read ATTACHMENT_UPLOAD_TARGET_PART_COUNT from LimitConstants.ts'); - } else if (!page.includes(`divided by ${divisor.toString()}`)) { - problems.push(`uploads.md does not say the part size is the declared size divided by ${divisor.toString()}`); - } let compared = 0; - for (const [name, label] of constants) { + for (const [name, label, file] of constants) { + const page = pages.find((entry) => entry.relativePath === file); + if (!page) throw new Error(`Missing upload documentation: ${file}`); const value = readConst(name); if (value == null) { problems.push(`could not read ${name} from LimitConstants.ts`); @@ -2052,9 +1876,9 @@ console.log('attachment upload geometry'); } compared += 1; const bare = new RegExp(`(?(); const documentedLabels = new Map(); for (const row of page.matchAll(/^\|\s*(GET|POST|PUT|PATCH|DELETE)\s*\|\s*(\/v1\/\S+?)\s*\|/gmu)) { - const normalised = shapeOf(row[1], stripVersionPrefix(row[2])); + const normalised = routeShape(row[1], stripVersionPrefix(row[2])); documented.add(normalised); documentedLabels.set(normalised, `${row[1]} ${row[2]}`); } @@ -2111,7 +1935,7 @@ console.log('bot capability flag (from the middleware chain)'); if (key.startsWith('GET /admin') || key.includes(' /admin/')) { continue; } - const documented = documentedFlags.get(key); + const documented = documentedMain.get(key); if (documented == null) { continue; } @@ -2171,7 +1995,7 @@ console.log('unauthenticated capability flag (from the middleware chain)'); if (key.startsWith('GET /admin') || key.includes(' /admin/')) { continue; } - const documented = documentedFlags.get(key); + const documented = documentedMain.get(key); if (documented == null) { continue; } @@ -2207,27 +2031,40 @@ console.log('spec security field against the middleware chain'); 'LoginRequired admits a bot token, but GuildOperationsService rejects every bot with 400 BOTS_CANNOT_CREATE_GUILDS, so the spec must not advertise botToken', ], ]); - const specSecurity = new Map>(); + const specSecurity = new Map(); for (const operation of main) { + const security = operation.security ?? []; const schemes = new Set(); - for (const entry of operation.security ?? []) { + for (const entry of security) { for (const scheme of Object.keys(entry)) { schemes.add(scheme); } } - specSecurity.set(shapeOf(operation.method, operation.path), schemes); + specSecurity.set(routeShape(operation.method, operation.path), { + schemes, + allowsAnonymous: security.length === 0 || security.some((entry) => Object.keys(entry).length === 0), + }); } const specBugs: Array = []; let compared = 0; for (const route of controllerRoutes) { const key = astRoute(route); - const declaredSchemes = specSecurity.get(key); - if (declaredSchemes == null || MANUAL_CREDENTIAL.has(key)) { + const declaredSecurity = specSecurity.get(key); + if (declaredSecurity == null || MANUAL_CREDENTIAL.has(key)) { continue; } + const declaredSchemes = declaredSecurity.schemes; compared += 1; const anyLogin = route.hasLoginRequired || route.hasLoginRequiredAllowSuspicious; const acceptsBot = anyLogin && !route.hasDefaultUserOnly; + const requiresAuthentication = + anyLogin || + route.hasDefaultUserOnly || + route.oauth2BearerTokenRequired || + route.middlewares.includes('requireOAuth2Scope'); + if (requiresAuthentication && declaredSecurity.allowsAnonymous) { + specBugs.push(`${key} allows unauthenticated requests, but the middleware chain requires authentication`); + } const exemption = BOT_SCHEME_EXEMPT.get(key); if (exemption != null) { if (declaredSchemes.has('botToken')) { @@ -2261,7 +2098,7 @@ const adminTargetOnly = [...documentedAdmin.entries()] .map(([, route]) => `${route.method} ${route.path} (${route.file})`) .sort(); const adminLiveOnly = admin - .filter((operation) => !documentedAdmin.has(shapeOf(operation.method, operation.path))) + .filter((operation) => !documentedAdmin.has(routeShape(operation.method, operation.path))) .map((operation) => `${operation.method} ${operation.path}`) .sort(); console.log(` live admin operations: ${admin.length.toString()}`); @@ -2283,3 +2120,151 @@ if (failures > 0) { } console.log('OK: every registered fluxer_api route is documented or covered by an exemption rule, and the'); console.log('documented routes match the live main API, media proxy, and admin target shape'); + +async function verifyInstallerExecution(installerRoot: string): Promise> { + const problems: Array = []; + const shellParse = spawnSync('sh', ['-n', path.join(installerRoot, 'install.sh')], {encoding: 'utf8'}); + if (shellParse.error != null) { + problems.push(`sh -n could not run against install.sh: ${shellParse.error.message}`); + } else if (shellParse.status !== 0) { + problems.push(`sh -n rejects install.sh: ${shellParse.stderr.trim()}`); + } + + const DOCKER_STUB = [ + '#!/bin/sh', + 'case "$1 $2" in', + " '--version ') echo 'Docker version 27.1.1, build stub' ;;", + " 'compose version') if [ \"$3\" = '--short' ]; then echo '2.30.3'; else echo 'v2.30.3'; fi ;;", + " 'compose config') echo 'ghcr.io/fluxerapp/fluxer-api:v1' ;;", + 'esac', + 'exit 0', + '', + ].join('\n'); + const CURL_STUB = [ + '#!/bin/sh', + "out=''", + "prev=''", + 'for arg in "$@"; do', + ' if [ "$prev" = \'-o\' ]; then out=$arg; fi', + ' prev=$arg', + 'done', + '[ -z "$out" ] || printf \'name: fluxer\\nservices:\\n api:\\n image: stub\\n\' > "$out"', + '', + ].join('\n'); + + const sandbox = await mkdtemp(path.join(tmpdir(), 'fluxer-installer-')); + try { + const stubBin = path.join(sandbox, 'bin'); + await mkdir(stubBin, {recursive: true}); + await writeFile(path.join(stubBin, 'docker'), DOCKER_STUB, {mode: 0o755}); + await writeFile(path.join(stubBin, 'curl'), CURL_STUB, {mode: 0o755}); + await writeFile(path.join(stubBin, 'openssl'), '#!/bin/sh\nexit 0\n', {mode: 0o755}); + + const instance = path.join(sandbox, 'instance'); + await mkdir(instance, {recursive: true}); + await writeFile(path.join(instance, '.env'), 'FLUXER_DOMAIN=x.example\nFLUXER_IMAGE_TAG=2026.813.205040\n'); + await writeFile(path.join(instance, 'docker-compose.yml'), 'name: fluxer\nservices:\n api:\n image: stub\n'); + + const planned = (label: string, args: ReadonlyArray, cwd?: string): string | null => { + const run = spawnSync('sh', [path.join(installerRoot, 'install.sh'), ...args], { + cwd, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ''}`, + ...(cwd == null ? {} : {PWD: cwd}), + }, + }); + if (run.error != null) { + problems.push(`install.sh ${label} could not run: ${run.error.message}`); + return null; + } + if (run.status !== 0) { + problems.push(`install.sh ${label} exited ${String(run.status)}: ${run.stderr.trim()}`); + return null; + } + return run.stdout; + }; + + const plannedRef = (label: string, args: ReadonlyArray): string | null => { + const stdout = planned(label, args); + if (stdout == null) { + return null; + } + const line = stdout.match(/^ {2}ref\s+(\S+)$/mu); + if (line == null) { + problems.push(`install.sh ${label} printed no ref line`); + return null; + } + return line[1]; + }; + + const INSTALL_ARGS = [ + '--dry-run', + '--non-interactive', + '--allow-root', + '--domain', + 'x.example', + '--email', + 'a@x.example', + '--dir', + path.join(sandbox, 'target'), + ]; + const REF_CASES: ReadonlyArray, string]> = [ + ['on the default image tag', INSTALL_ARGS, 'main'], + ['under --image-tag latest', [...INSTALL_ARGS, '--image-tag', 'latest'], 'main'], + ['under --image-tag 2026.813.205040', [...INSTALL_ARGS, '--image-tag', '2026.813.205040'], '2026.813.205040'], + [ + 'under --ref feature/x --image-tag 2026.813.205040', + [...INSTALL_ARGS, '--ref', 'feature/x', '--image-tag', '2026.813.205040'], + 'feature/x', + ], + [ + 'under --update against a pinned .env', + ['--update', '--dry-run', '--allow-root', '--dir', instance], + '2026.813.205040', + ], + ]; + for (const [label, args, expected] of REF_CASES) { + const resolved = plannedRef(label, args); + if (resolved != null && resolved !== expected) { + problems.push(`install.sh ${label} plans ref ${resolved}, and the image tag it pairs with wants ${expected}`); + } + } + + const composeYmlInstance = path.join(sandbox, 'compose-yml-instance'); + await mkdir(composeYmlInstance, {recursive: true}); + await writeFile( + path.join(composeYmlInstance, '.env'), + 'FLUXER_DOMAIN=x.example\nFLUXER_IMAGE_TAG=2026.813.205040\n', + ); + await writeFile(path.join(composeYmlInstance, 'compose.yml'), 'name: fluxer\nservices:\n api:\n image: stub\n'); + const COMPOSE_NAME_CASES: ReadonlyArray, string | undefined]> = [ + [ + 'under --update against a compose.yml instance', + ['--update', '--dry-run', '--allow-root', '--dir', composeYmlInstance], + undefined, + ], + [ + 'under --update standing in a compose.yml instance', + ['--update', '--dry-run', '--allow-root'], + composeYmlInstance, + ], + ]; + for (const [label, args, cwd] of COMPOSE_NAME_CASES) { + const stdout = planned(label, args, cwd); + if (stdout == null) { + continue; + } + if (!/^ {4}compose\.yml is unchanged$/mu.test(stdout)) { + problems.push(`install.sh ${label} does not plan the refreshed stack file onto compose.yml`); + } + if (/^ {4}docker-compose\.yml is new$/mu.test(stdout)) { + problems.push(`install.sh ${label} plans a docker-compose.yml that Compose would never load there`); + } + } + } finally { + await rm(sandbox, {recursive: true, force: true}); + } + return problems; +} diff --git a/fluxer_docs/scripts/VerifyDocsSchemas.ts b/fluxer_docs/scripts/VerifyDocsSchemas.ts index d8515a258..afa54b8a2 100644 --- a/fluxer_docs/scripts/VerifyDocsSchemas.ts +++ b/fluxer_docs/scripts/VerifyDocsSchemas.ts @@ -9,14 +9,13 @@ import type { OpenAPIDocument as Spec, } from '@fluxer/openapi/src/OpenAPITypes'; -import {DOCS_ROOT, listMarkdownFiles, slugifyHeading} from './DocsSource.ts'; +import {readRouteHeaders} from './DocsRouteHeaders.ts'; +import {DOCS_ROOT, HTTP_METHODS, readMarkdownPages, routeShape, slugifyHeading, splitTableRow} from './DocsSource.ts'; const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); const MAIN_SPEC = path.join(REPO_ROOT, 'fluxer_api/src/api/openapi/openapi.json'); const ADMIN_SPEC = path.join(REPO_ROOT, 'fluxer_admin/openapi-admin.json'); -const ROUTE_HEADER = /)[key]; + } + if (typeof target === 'boolean') return target; + if (target === null || typeof target !== 'object' || Array.isArray(target)) { + throw new Error(`Reference does not identify a schema: ${reference}`); + } + return target as SchemaNode; } function resolveRef(spec: Spec, node: SchemaNode | boolean | undefined, depth = 0): SchemaNode | undefined { - if (node == null || typeof node === 'boolean') { + if (node == null || node === false) { return undefined; } + if (node === true) { + return {}; + } if (depth > 64) { throw new Error('OpenAPI reference chain exceeds the supported depth'); } if (node.$ref != null) { - const prefix = '#/components/schemas/'; - if (!node.$ref.startsWith(prefix)) { - throw new Error(`Unsupported schema reference: ${node.$ref}`); - } - const name = decodeURIComponent(node.$ref.slice(prefix.length)).replace(/~1/gu, '/').replace(/~0/gu, '~'); - const target = spec.components.schemas[name]; - if (target == null) { - throw new Error(`Missing schema reference: ${node.$ref}`); - } + const target = resolveSchemaPointer(spec, node.$ref); const resolved = resolveRef(spec, target, depth + 1); const {$ref, ...siblings} = node; if (resolved == null || Object.keys(siblings).length === 0) { return resolved; } + if (Object.keys(resolved).length === 0) { + return siblings; + } return {...resolved, allOf: [...(resolved.allOf ?? []), siblings]}; } return node; @@ -195,20 +214,27 @@ function operationIndex(spec: Spec): Map { const index = new Map(); for (const [routePath, item] of Object.entries(spec.paths)) { for (const [method, operation] of Object.entries(item)) { - index.set(shape(method.toUpperCase(), stripVersion(routePath)), operation); + const upper = method.toUpperCase(); + if (!HTTP_METHODS.has(upper)) continue; + const key = routeShape(upper, stripVersion(routePath)); + if (index.has(key)) throw new Error(`Duplicate OpenAPI operation: ${key}`); + index.set(key, operation); } } return index; } -function cleanFieldName(cell: string): string | null { - const name = cell +function fieldNameText(cell: string): string { + return cell .replace(/.*?<\/sup>/gu, '') .replace(/\*\*/gu, '') .replace(/`/gu, '') .replace(/\\/gu, '') - .trim() - .replace(/\?$/u, ''); + .trim(); +} + +function cleanFieldName(cell: string): string | null { + const name = fieldNameText(cell).replace(/\?$/u, ''); if (name.length === 0) { return null; } @@ -239,12 +265,11 @@ function sectionIsByReference(lines: ReadonlyArray, start: number): bool return sawReference && !sawTable; } -function tableOptionality(lines: ReadonlyArray, start: number): Map { - const out = new Map(); +function* firstTableRows(lines: ReadonlyArray, start: number): Generator { let index = start; while (index < lines.length && !lines[index].startsWith('|')) { if (lines[index].startsWith('#')) { - return out; + return; } index += 1; } @@ -253,79 +278,32 @@ function tableOptionality(lines: ReadonlyArray, start: number): Map, start: number): Map { + const out = new Map(); + for (const line of firstTableRows(lines, start)) { + const cells = splitTableRow(line); + if (cells.length === 0) { continue; } - const raw = cells[0] - .replace(/.*?<\/sup>/gu, '') - .replace(/`/gu, '') - .trim(); + const raw = fieldNameText(cells[0]); const name = cleanFieldName(cells[0]); if (name == null) { continue; } - out.set(name, raw.endsWith('?')); + out.set(name, {type: cells[1] === undefined ? null : normaliseDocType(cells[1]), optional: raw.endsWith('?')}); } return out; } -function tableFieldTypes(lines: ReadonlyArray, start: number): Map { - const out = new Map(); - let index = start; - while (index < lines.length && !lines[index].startsWith('|')) { - if (lines[index].startsWith('#')) { - return out; - } - index += 1; - } - for (; index < lines.length; index += 1) { - const line = lines[index]; - if (!line.startsWith('|')) { - break; - } - const cells = line.split('|').slice(1, -1); - if (cells.length < 2) { - continue; - } - const name = cleanFieldName(cells[0]); - if (name == null) { - continue; - } - const type = normaliseDocType(cells[1]); - if (type != null) { - out.set(name, type); - } - } - return out; -} - -function tableFields(lines: ReadonlyArray, start: number): Set { - const fields = new Set(); - let index = start; - while (index < lines.length && !lines[index].startsWith('|')) { - if (lines[index].startsWith('#')) { - return fields; - } - index += 1; - } - for (; index < lines.length; index += 1) { - const line = lines[index]; - if (!line.startsWith('|')) { - break; - } - const match = line.match(TABLE_ROW); - if (match == null) { - continue; - } - const name = cleanFieldName(match[1]); - if (name != null) { - fields.add(name); - } - } - return fields; -} - const mainSpec: Spec = JSON.parse(await readFile(MAIN_SPEC, 'utf8')); const adminSpec: Spec = JSON.parse(await readFile(ADMIN_SPEC, 'utf8')); const mainIndex = operationIndex(mainSpec); @@ -352,18 +330,16 @@ function documentReferences(page: string, line: string): Set { return references; } -const allFiles = await listMarkdownFiles(DOCS_ROOT); +const pages = await readMarkdownPages(DOCS_ROOT); const anchorFields = new Map>(); const anchorTypes = new Map>(); const anchorReferences = new Map>(); const objectAnchors = new Set(); -for (const file of allFiles) { - const slug = path - .relative(DOCS_ROOT, file) +for (const {relativePath, lines} of pages) { + const slug = relativePath .replace(/\.(mdx|md)$/u, '') .replace(/\/index$/u, '') .replace(/^index$/u, ''); - const lines = (await readFile(file, 'utf8')).split('\n'); let currentAnchors: Array = []; const pendingAnchors: Array = []; for (let i = 0; i < lines.length; i += 1) { @@ -398,15 +374,14 @@ for (const file of allFiles) { if (currentAnchors.length === 0 || !line.startsWith('|')) { continue; } - const row = line.match(TABLE_ROW); - if (row == null) { + const cells = splitTableRow(line); + if (cells.length === 0) { continue; } - const name = cleanFieldName(row[1]); + const name = cleanFieldName(cells[0]); if (name == null) { continue; } - const cells = line.split('|').slice(1, -1); const declaredType = cells.length >= 2 ? normaliseDocType(cells[1]) : null; for (const anchor of currentAnchors) { const anchorKey = `${slug}#${anchor}`; @@ -436,22 +411,22 @@ let typesCompared = 0; let optionalityCompared = 0; const optionalityAdvisories: Array = []; -for (const file of allFiles) { - const relative = path.relative(DOCS_ROOT, file); +for (const page of pages) { + const {relativePath: relative, lines} = page; if (relative.startsWith('media-proxy/')) { continue; } - const lines = (await readFile(file, 'utf8')).split('\n'); + const routeHeaders = readRouteHeaders(page); const pageFields = new Set(); for (let i = 0; i < lines.length; i += 1) { if (!lines[i].startsWith('|')) { continue; } - const match = lines[i].match(TABLE_ROW); - if (match == null) { + const cells = splitTableRow(lines[i]); + if (cells.length === 0) { continue; } - const name = cleanFieldName(match[1]); + const name = cleanFieldName(cells[0]); if (name != null) { pageFields.add(name); } @@ -475,18 +450,14 @@ for (const file of allFiles) { } for (const section of sections) { - let header: RegExpMatchArray | null = null; - for (let i = section.start; i < section.end; i += 1) { - const match = lines[i].match(ROUTE_HEADER); - if (match != null) { - header = match; - break; - } - } + const header = routeHeaders.find((candidate) => candidate.line > section.start && candidate.line <= section.end); if (header == null) { continue; } - const key = shape(header[1], stripVersion(header[2])); + if (header.endLine > section.end) { + throw new Error(`${relative}:${header.line}: RouteHeader crosses a section boundary`); + } + const key = routeShape(header.method, stripVersion(header.path)); const operation = index.get(key); if (operation == null) { continue; @@ -580,9 +551,9 @@ for (const file of allFiles) { continue; } checkedBodies += 1; - const documentedTypes = tableFieldTypes(lines, i + 1); const actualTypes = collectPropertyTypes(spec, jsonSchema); - for (const [field, docType] of documentedTypes) { + for (const [field, {type: docType}] of documented) { + if (docType === null) continue; const specType = actualTypes.get(field); if (specType == null) { continue; @@ -601,11 +572,9 @@ for (const file of allFiles) { detail: `${field}: documented ${docType}, schema ${specType}`, }); } - const documentedOptional = tableOptionality(lines, i + 1); const requiredFields = collectRequired(spec, jsonSchema); - const bodyProperties = collectProperties(spec, jsonSchema); - for (const [field, isOptional] of documentedOptional) { - if (!bodyProperties.has(field)) { + for (const [field, {optional: isOptional}] of documented) { + if (!actual.has(field)) { continue; } const specRequired = requiredFields.has(field); @@ -619,7 +588,7 @@ for (const file of allFiles) { : `${relative} ${key} ${field}: documented required, schema marks it optional`, ); } - for (const field of documented) { + for (const field of documented.keys()) { if (actual.has(field)) { continue; } @@ -650,7 +619,7 @@ for (const file of allFiles) { continue; } checkedQueries += 1; - for (const field of documented) { + for (const field of documented.keys()) { if (!actual.has(field)) { mismatches.push({page: relative, operation: key, kind: 'query-extra', detail: field}); } @@ -700,7 +669,7 @@ console.log(`bodies documented by reference to an object section: ${documentedBy console.log(`query parameter tables checked: ${checkedQueries.toString()}`); console.log(`success response schemas checked: ${responsesChecked.toString()}`); console.log(`response fields found documented on the page: ${responseFieldsFound.toString()}`); -console.log(`request body field types compared: ${typesCompared.toString()}`); +console.log(`request and response field types compared: ${typesCompared.toString()}`); console.log(`request body optionality compared: ${optionalityCompared.toString()}`); console.log(`optionality advisories: ${optionalityAdvisories.length.toString()}`); if (optionalityAdvisories.length > 0) { @@ -732,4 +701,4 @@ if (mismatches.length > 0) { console.error(`FAIL: ${mismatches.length.toString()} field mismatches`); process.exit(1); } -console.log('OK: every documented request body and query table matches the live schema'); +console.log('OK: no field mismatches found in the checked tables and checked-in OpenAPI schemas'); diff --git a/fluxer_docs/scripts/VerifyDocsStyle.ts b/fluxer_docs/scripts/VerifyDocsStyle.ts index b3be37d56..9cfa18382 100644 --- a/fluxer_docs/scripts/VerifyDocsStyle.ts +++ b/fluxer_docs/scripts/VerifyDocsStyle.ts @@ -3,7 +3,7 @@ import {readdir, readFile} from 'node:fs/promises'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; -import {DOCS_ROOT, listMarkdownFiles} from './DocsSource.ts'; +import {DOCS_ROOT, readMarkdownPages} from './DocsSource.ts'; import { columnWidthPercents, extractTables, @@ -67,23 +67,10 @@ function frontmatterOf(source: string): string | null { return source.slice(4, end); } -function insideFence(lines: ReadonlyArray, index: number): boolean { - let fenced = false; - for (let cursor = 0; cursor < index; cursor += 1) { - if (lines[cursor].startsWith('```')) { - fenced = !fenced; - } - } - return fenced; -} - -const files = await listMarkdownFiles(DOCS_ROOT); +const pages = await readMarkdownPages(DOCS_ROOT); const findings: Array = []; -for (const file of files) { - const relative = path.relative(DOCS_ROOT, file); - const source = await readFile(file, 'utf8'); - const lines = source.split('\n'); +for (const {file, relativePath: relative, source, lines} of pages) { const frontmatter = frontmatterOf(source); if (frontmatter == null) { @@ -109,10 +96,15 @@ for (const file of files) { findings.push({file: relative, line: 1, rule: 'extension', detail: 'uses RouteHeader but is not .mdx'}); } + let fenced = false; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; const number = index + 1; - if (insideFence(lines, index)) { + const insideFence = fenced; + if (line.startsWith('```')) { + fenced = !fenced; + } + if (insideFence) { continue; } if (line.includes('—')) { @@ -140,9 +132,7 @@ for (const file of files) { } } -for (const file of files) { - const relative = path.relative(DOCS_ROOT, file); - const lines = (await readFile(file, 'utf8')).split('\n'); +for (const {relativePath: relative, lines} of pages) { const sectionStarts: Array = []; for (let index = 0; index < lines.length; index += 1) { if (lines[index].startsWith('## ') && !lines[index].startsWith('### ')) { @@ -167,15 +157,13 @@ for (const file of files) { file: relative, line: response + 1, rule: 'order', - detail: '"### Response body" must precede "### Response" (conventions.md)', + detail: '"### Response body" must precede "### Response"', }); } } } -for (const file of files) { - const relative = path.relative(DOCS_ROOT, file); - const lines = (await readFile(file, 'utf8')).split('\n'); +for (const {relativePath: relative, lines} of pages) { let block = new Map(); let sinceFootnote = 0; for (let index = 0; index < lines.length; index += 1) { @@ -206,10 +194,7 @@ for (const file of files) { const NOTATION_EXAMPLE = 'A superscript marker such as 1 refers to the numbered footnote'; -for (const file of files) { - const relative = path.relative(DOCS_ROOT, file); - const source = await readFile(file, 'utf8'); - const lines = source.split('\n'); +for (const {relativePath: relative, source, lines} of pages) { const explainsNotation = source.includes(NOTATION_EXAMPLE); const bounds: Array = [0]; for (let index = 0; index < lines.length; index += 1) { @@ -292,8 +277,6 @@ const ACCEPTED_TABLE_FINDINGS = new Map>>(); let tablesMeasured = 0; const overWideTier: Array = []; -for (const file of files) { - const relative = path.relative(DOCS_ROOT, file); - const source = await readFile(file, 'utf8'); +for (const {relativePath: relative, source} of pages) { for (const table of extractTables(source)) { tablesMeasured += 1; const raise = (rule: TableRule, detail: string): void => { @@ -541,7 +519,7 @@ for (const finding of findings) { byRule.set(finding.rule, (byRule.get(finding.rule) ?? 0) + 1); } -console.log(`pages checked: ${files.length.toString()}`); +console.log(`pages checked: ${pages.length.toString()}`); console.log(`tables measured: ${tablesMeasured.toString()}`); for (const rule of TABLE_RULES) { diff --git a/fluxer_docs/src/content/docs/admin-api/api-keys.mdx b/fluxer_docs/src/content/docs/admin-api/api-keys.mdx index ea31eb8ae..dd1c7b448 100644 --- a/fluxer_docs/src/content/docs/admin-api/api-keys.mdx +++ b/fluxer_docs/src/content/docs/admin-api/api-keys.mdx @@ -79,14 +79,14 @@ Only [Create Admin API key](#create-admin-api-key) returns this object. It has t | expires_at2 | ?ISO8601 timestamp | Time the key expires, or null when the key does not expire | | acls3 | array[string] | The [ACLs](/admin-api/#acl-registry) stored on the key | -1 Has the form `fa__<32 characters>` described by [token formats](/authentication/#token-formats), and is presented as `Admin ` in the `Authorization` header +1 An opaque key, presented as `Admin ` in the [Authorization header](/authentication/#authorization-schemes) 2 Derived from `expires_in_days` at the instant the key is created, and null when that field is omitted 3 Reflects the stored set, so a value repeated in the request appears once :::caution[The secret is returned once] -Fluxer stores the value as a password hash, so no later operation returns it and none rotates it. A key whose raw value is lost must be revoked and replaced. +The secret cannot be retrieved or rotated later. If it is lost, revoke the key and create a replacement. ::: ### Example diff --git a/fluxer_docs/src/content/docs/admin-api/applications.mdx b/fluxer_docs/src/content/docs/admin-api/applications.mdx index 6502f6593..7652f8104 100644 --- a/fluxer_docs/src/content/docs/admin-api/applications.mdx +++ b/fluxer_docs/src/content/docs/admin-api/applications.mdx @@ -6,7 +6,7 @@ description: Application inspection, the owner and guild selectors, and ownershi import RouteHeader from '@/components/RouteHeader.astro'; -An application is an OAuth2 client, and it can own one bot account. These routes report its ownership, bot state, redirect configuration, and non-secret credential metadata. [Transfer application ownership](#transfer-application-ownership) is the only one that writes. +An application is an OAuth2 client that can own one bot account. These routes inspect applications and [transfer their ownership](#transfer-application-ownership). These records are the same ones the public [Applications](/http-api/applications/) resource serves. Creation, deletion, renaming, bot creation, redirect URI editing, and credential rotation stay there. @@ -204,7 +204,7 @@ Ownership is the only field this operation writes. The new owner need not be rel ### Side effects -Fluxer rewrites the record with the new `owner_user_id` and increments its `version`. The application moves in both the `owner_id` selector of [List applications](#list-applications) and [List user applications](/admin-api/users/#list-user-applications). The operation emits no Gateway Dispatch. +The transfer changes `owner_user_id`, increments `version`, and updates the results of [List applications](#list-applications) and [List user applications](/admin-api/users/#list-user-applications). It emits no Gateway Dispatch. Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with the target type `application`, the target ID equal to the application ID, and the action `transfer_ownership`. Its metadata is `old_owner_id` and `new_owner_id`. Neither owner is resolved into `related_users`. diff --git a/fluxer_docs/src/content/docs/admin-api/archives.mdx b/fluxer_docs/src/content/docs/admin-api/archives.mdx index c1b1a2a3a..cf3224f42 100644 --- a/fluxer_docs/src/content/docs/admin-api/archives.mdx +++ b/fluxer_docs/src/content/docs/admin-api/archives.mdx @@ -8,6 +8,8 @@ import RouteHeader from '@/components/RouteHeader.astro'; An archive is a snapshot of one user account or one guild, built in the background and downloaded as a file. [Create user archive](/admin-api/users/#create-user-archive) and [Create guild archive](/admin-api/guilds/#create-guild-archive) request one. The operations here read archive state and issue time-limited download URLs. +Assets that no longer exist are omitted. Other read or write failures fail the attempt. + Every operation needs an [ACL](/admin-api/#acl-evaluation) covering the subject type it touches. - A user archive needs `archive:view_all` or `archive:trigger:user`. @@ -15,7 +17,7 @@ Every operation needs an [ACL](/admin-api/#acl-evaluation) covering the subject - Reading both types at once needs `archive:view_all`, or `archive:trigger:user` and `archive:trigger:guild` together. :::note[An expired archive stops being readable] -`expires_at` is 365 days after the archive is requested and is never extended. The archive record is removed at that instant, and no operation reads the stored file afterwards. +An archive becomes unavailable through these routes at `expires_at`, 365 days after it was requested. Its lifetime is never extended. ::: ## Archive object @@ -45,9 +47,9 @@ An archive is in one of these lifecycle states. It is building while `completed_ 2 A new archive starts at 0 with the step `Queued`, and completion sets 100 and `Completed`. Failure leaves `progress_percent` where it was and sets the step to `Failed` -3 Written once, at completion, to one year after the archive file was uploaded. [Create archive download](#create-archive-download) returns a URL with its own expiry, and no operation here updates this field +3 Written at completion using the archive's fixed `expires_at`. [Create archive download](#create-archive-download) returns a URL with its own expiry -4 The value is 365 days after `requested_at`, and every write fills the field in +4 The value is 365 days after `requested_at` ### Example @@ -123,7 +125,7 @@ Returns [archive](#archive-object) objects matching the supplied filters, newest 4 The listing is not paginated and returns no cursor. Only a narrower filter reaches older records -5 The value counts as true only for `true`, `True`, or `1`. Expired archives are dropped after `limit` rows have been read, so a response can hold fewer than `limit` archives +5 The value counts as true only for `true`, `True`, or `1`. Excluding expired archives can leave fewer than `limit` results :::note[The default filter narrows to the account's ACLs] `all` is the default, and Fluxer resolves it to the subject types the account's ACLs cover. An account holding only `archive:trigger:user` reads user archives, one holding only `archive:trigger:guild` reads guild archives, and one holding `archive:view_all` reads both. An account holding none of the three is refused with 403 `MISSING_ACL`. diff --git a/fluxer_docs/src/content/docs/admin-api/blocklists.mdx b/fluxer_docs/src/content/docs/admin-api/blocklists.mdx index 0606d3cac..5f7904283 100644 --- a/fluxer_docs/src/content/docs/admin-api/blocklists.mdx +++ b/fluxer_docs/src/content/docs/admin-api/blocklists.mdx @@ -6,7 +6,7 @@ description: The safety blocklists, their value forms, and the operations that m import RouteHeader from '@/components/RouteHeader.astro'; -A blocklist is a stored set of values Fluxer checks account access and user content against. Nine lists exist. Each has one canonical value form, one matching rule, and its own stored fields. Fluxer canonicalises every value before storage and before every check. +Fluxer has nine blocklists for account access and user content. Each defines its accepted values, matching rules and metadata. Values are normalised consistently when added and checked. Each list has its own [Admin ACLs](/admin-api/#acl-registry). A read needs the selected list's `check` permission, an addition or an update needs its `add` permission, and a removal needs its `remove` permission. An account holding `ban:ip:add` writes to the `ip` list and to no other. Every write records the audit reason on the [Admin audit entries](/admin-api/#admin-audit-entry-object) it produces. The reads record nothing. @@ -420,7 +420,7 @@ Every hash is written with the category `manual`, the severity `2`, and a null c ### Side effects -The job lowercases each hash and writes it as an upsert. It publishes one refresh notice after the last hash, so a job cancelled part way leaves the hashes it wrote invisible to other nodes. They become visible on a later write, or on the feed sync that runs every twelve hours. No Gateway Dispatch is emitted. +Each hash is lowercased and replaces any existing entry. Changes become visible across the instance when the job completes. If it is cancelled, changes already made can remain unapplied on other nodes until a later blocklist update or the twelve-hour feed sync. No Gateway Dispatch is emitted. The job records one aggregate [Admin audit entry](/admin-api/#admin-audit-entry-object) under the action `bulk_ban_file_shas`, with the submitted, successful, and failed counts. A cancelled job records none. @@ -454,7 +454,7 @@ The body is selected by `list_type`. 1 `avatar-hash` only, where it is the whole body -2 `profile-substring` only, where both are required. The body reuses the creation shape, so `reason` and `notes` parse and are then ignored +2 `profile-substring` only, where both are required. `reason` and `notes` are accepted but ignored ### Response @@ -469,7 +469,7 @@ The operation is idempotent and reports no counts. A value with no stored row st ### Side effects -Every removed row stops affecting subsequent blocklist decisions, and one refresh notice follows the last value. A value can remain blocked by another matching row. No Gateway Dispatch is emitted. +Removed entries stop affecting subsequent blocklist decisions after changes propagate across the instance. A value can remain blocked by another matching entry. No Gateway Dispatch is emitted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) per canonical value processed. @@ -598,7 +598,7 @@ Changing the `scope` of a `profile-substring` row writes a second row under the ### Side effects -The written fields take effect for subsequent matches, and the write publishes a refresh notice. No Gateway Dispatch is emitted. +The updated fields take effect for subsequent matches after changes propagate across the instance. No Gateway Dispatch is emitted. The write records one [Admin audit entry](/admin-api/#admin-audit-entry-object) under the same action an add records, with the canonical value but no previous values of the changed fields. @@ -696,7 +696,7 @@ Every account using that image shares the same truncated prefix, so blocking the ### Side effects -The hash takes effect for subsequent avatar uploads, and the write publishes a refresh notice. No Gateway Dispatch is emitted. +The hash blocks subsequent avatar uploads after the change propagates across the instance. No Gateway Dispatch is emitted. Fluxer records one [Admin audit entry](/admin-api/#admin-audit-entry-object) under the action `ban_avatar_hash`, with the stored hash and the supplied reason. diff --git a/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx b/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx index 8c668c710..f44940460 100644 --- a/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx +++ b/fluxer_docs/src/content/docs/admin-api/bulk-jobs.mdx @@ -6,7 +6,7 @@ description: Administrative bulk mutations over an explicit user or guild set. import RouteHeader from '@/components/RouteHeader.astro'; -A bulk job applies one operation to an explicit set of users or guilds. The `task` discriminator selects the payload contract, the [Admin ACL](/admin-api/#acl-registry) evaluated for the request, and the worker task that runs. +A bulk job applies one operation to an explicit set of users or guilds. The `task` field selects the request body and required [Admin ACL](/admin-api/#acl-registry). Every target is a list of IDs. There is no search, role, tag, email, or IP selector, so the caller resolves its own target set first with [List users](/admin-api/users/#list-users) or [List guilds](/admin-api/guilds/#list-guilds). @@ -22,7 +22,7 @@ A bulk job creation has the identifier of the queued job. | --- | --- | --- | | job_id1 | snowflake | The job that applies the requested operation, readable through [Get job](/admin-api/jobs/#get-job) | -1 Fluxer writes the ledger row before it queues the job, so the identifier in a 200 is always readable +1 The job is readable as soon as this response arrives ### Example @@ -144,11 +144,11 @@ The `task` discriminator selects one of these structures. Every ID array has an ### Side effects -This operation writes no Admin audit entry. It records one [Jobs](/admin-api/jobs/) ledger row naming the acting Admin as the requester and storing the audit reason, then queues the worker task. When the ledger row cannot be written, Fluxer returns 500 `INTERNAL_SERVER_ERROR` and queues nothing. +The [job](/admin-api/jobs/) records the acting Admin and audit reason. Queueing writes no Admin audit entry. Failure to create the job returns 500 `INTERNAL_SERVER_ERROR` without starting the operation. -The worker processes entities in the submitted order, one at a time. A cancellation check runs before each entity, so [Cancel job](/admin-api/jobs/#cancel-job) stops the run between two entities and settles the job as `cancelled`. Every entity changed before cancellation or failure stays changed. An entity that fails, including an ID that resolves to nothing, is counted as failed and skipped, and the run continues through the rest of the set. +Entities are processed in the submitted order. [Cancel job](/admin-api/jobs/#cancel-job) stops the run between entities and sets its status to `cancelled`. Completed changes remain in place. A failed or unknown entity counts as failed without stopping the remaining work. -The worker reports progress before the run starts, after every 25 entities, and once at the end. `schedule_user_deletion` reports after every 10 accounts instead. The closing progress message has the successful and failed counts. +Progress updates arrive before work starts, after every 25 entities, and at completion. `schedule_user_deletion` updates after every 10 accounts instead. The final message includes successful and failed counts. Every task writes one summary Admin audit entry when it finishes, with the action `bulk_update_user_flags`, `bulk_update_suspicious_activity_flags`, `bulk_update_guild_features`, `bulk_add_guild_members`, or `bulk_schedule_deletion`. The summary has the audit reason, the entity count, the operation-specific parameters, and the successful and failed counts. Its `target_id` is the guild for `add_guild_members` and `0` for every other task. A cancelled or failed job writes no summary entry. diff --git a/fluxer_docs/src/content/docs/admin-api/discovery.mdx b/fluxer_docs/src/content/docs/admin-api/discovery.mdx index fc0ae8b30..256a6086c 100644 --- a/fluxer_docs/src/content/docs/admin-api/discovery.mdx +++ b/fluxer_docs/src/content/docs/admin-api/discovery.mdx @@ -149,7 +149,7 @@ Every `category_type` and `category_id` here is an integer of 0 through 8. A val Returns every pending application as an array of [Admin pending application](#admin-pending-application-object) objects. Requires `discovery:review`. -Applications arrive in storage order, keyed by application time and then by guild ID. That direction is not specified, so a client that needs a stable order sorts the array itself. The response is a bare array, and the operation accepts no cursor and no limit. +The response is a bare array with no guaranteed order, cursor or limit. Sort it locally if a stable order is required. A guild approved automatically on submission never appears here. An application whose stored record can no longer be read is dropped from the array. The request still succeeds. @@ -271,7 +271,7 @@ A rebuild of the discovery search index does not change what this operation answ Returns every approved listing as an array of [Admin discovery listing](#admin-discovery-listing-object) objects. Requires `discovery:review`. -Listings arrive in the same unspecified storage order as [List discovery applications](#list-discovery-applications). The response is a bare array, and the operation accepts no cursor and no limit. A pending, rejected, or removed application is never returned. +The response is a bare array with no guaranteed order, cursor or limit. Pending, rejected and removed applications are excluded. ### Response @@ -422,4 +422,3 @@ A removal whose guild can no longer be resolved still succeeds. Fluxer skips the ### Rate limit 20 requests per 10 seconds for each authenticated user, on the `discovery:admin:action` bucket. - diff --git a/fluxer_docs/src/content/docs/admin-api/gateway.mdx b/fluxer_docs/src/content/docs/admin-api/gateway.mdx index ca443915d..15b827ca0 100644 --- a/fluxer_docs/src/content/docs/admin-api/gateway.mdx +++ b/fluxer_docs/src/content/docs/admin-api/gateway.mdx @@ -8,7 +8,7 @@ import RouteHeader from '@/components/RouteHeader.astro'; Gateway control is the Admin view of the running [main Gateway](/gateway/overview/) cluster. It reads live node state and submits guild reload requests, and it exposes no session, presence, or call payload. -Fluxer answers every operation on this page with one RPC call to the main Gateway. An unanswered call returns 504 `GATEWAY_TIMEOUT`. An overloaded cluster, or one with no responder, returns 503 `SERVICE_UNAVAILABLE`. A reply that cannot be interpreted returns 502 `BAD_GATEWAY`. +An unanswered Gateway request returns 504 `GATEWAY_TIMEOUT`. An overloaded or unavailable cluster returns 503 `SERVICE_UNAVAILABLE`. An invalid Gateway response returns 502 `BAD_GATEWAY`. :::note[Every value is live state] The Gateway cluster answers each read at request time. Two consecutive reads can differ without any Admin action, and a node that is restarting can be absent from one response and present in the next. @@ -99,7 +99,7 @@ Byte counts for one node, or for every polled node summed together. Each count i ## Guild memory statistics object -One entry for each live guild process the read sampled. Every value except `nsfw_level` is read from the guild process itself. +Memory and activity for one live guild. ### Structure @@ -281,7 +281,7 @@ Every reloaded guild process fires one [Guild Update](/gateway/events/#guild-upd ### Side effects -Each node reloads its selection in batches of ten with a 100 millisecond delay between batches. A guild whose owner node cannot be resolved is not counted. The response returns once the last batch has been dispatched, so an individual reload can still be in progress when the caller receives it. No guild data is changed and no Admin audit entry is recorded. +A guild whose owner node cannot be resolved is not counted. Reloads can still be in progress when the response arrives. No guild data is changed and no Admin audit entry is recorded. ### Rate limit diff --git a/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx b/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx index d2935c878..77b0573df 100644 --- a/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx +++ b/fluxer_docs/src/content/docs/admin-api/gift-codes.mdx @@ -47,7 +47,7 @@ Generates the requested number of unredeemed gift codes. Requires `gift_codes:ge | --- | --- | --- | | codes1 | array[string] | The complete redemption links, one for each generated code, in generation order | -1 Each entry is the instance's configured gift endpoint, with one trailing slash removed if present, followed by `/` and the code +1 Each link uses the instance's configured gift endpoint followed by `/` and the code ```json { @@ -75,7 +75,7 @@ Reading a code back requires presenting it to [Get gift](/http-api/gifts/#get-gi Each code is 32 characters drawn from the uppercase letters, the lowercase letters, and the digits. Fluxer creates it unredeemed and records the system account with ID `0` as its creator, which is the creator the [gift object](/http-api/gifts/#gift-object) reports to the redeemer. -Fluxer writes the codes one at a time. A failure part way through leaves the codes already written redeemable while the response reports the failure. Those codes appear in no response. +A failed request can leave some codes redeemable without returning them. Retrying can therefore create additional codes. The operation records no Admin audit entry and emits no Gateway Dispatch. diff --git a/fluxer_docs/src/content/docs/admin-api/guilds.mdx b/fluxer_docs/src/content/docs/admin-api/guilds.mdx index 135b75944..51b032a48 100644 --- a/fluxer_docs/src/content/docs/admin-api/guilds.mdx +++ b/fluxer_docs/src/content/docs/admin-api/guilds.mdx @@ -379,7 +379,7 @@ The groups run in a fixed order: image clears, settings, features, name, custom ### Side effects -Clearing an image field queues the previous stored object for deletion. Replacing the custom invite code deletes the invite record holding the previous code and creates one for the new code. Sending null deletes the previous record without creating another. +Clearing an image schedules its deletion. Replacing or clearing the custom invite code invalidates the previous invite link. Supplying `add_features` or `remove_features` reconciles an existing discovery application. The application is approved when [DISCOVERABLE](/http-api/guilds/#guild-features) becomes present and it is not already approved, and it is marked removed when `DISCOVERABLE` becomes absent and it was approved. A guild that has never applied for [discovery](/admin-api/discovery/) gains no application. @@ -423,15 +423,13 @@ Permanently deletes a guild and every record it owns. Requires `guild:delete`. | 200 | response body | Guild was deleted | | 404 | [error response](/admin-api/#error-response) | `UNKNOWN_GUILD`, because the guild does not exist | -:::danger[A guild archive is the only way back] +:::danger[Archive content before deleting the guild] The guild, its channels, roles, memberships, messages, and attachments are destroyed as soon as the request is accepted. Take a [guild archive](#create-guild-archive) first when the content has to be retained. ::: ### Side effects -Fluxer emits one [Guild Delete](/gateway/events/#guild-delete) Dispatch first, then detaches every member from the guild on the main Gateway. It deletes each member's [guild settings](/http-api/users/settings/) entry for the guild, and drops each human member's [guild folder](/http-api/users/settings/) references to the guild. - -Every invite, every webhook, every message of every channel, and every channel attachment are deleted. Any discovery application is deleted. The guild record is then deleted, the guild is stopped on the main Gateway, and it is removed from the guild search index. +Members receive [Guild Delete](/gateway/events/#guild-delete). The guild disappears from their [settings and folders](/http-api/users/settings/) and from search. Its invites, webhooks, messages, attachments and discovery application are also deleted. One Admin audit entry is recorded with the action `delete_guild`, the target type `guild`, and the guild ID in both the target and the metadata. @@ -559,7 +557,7 @@ The acting account must see the guild, hold [KICK_MEMBERS](/http-api/permissions ### Side effects -Fluxer snapshots the membership metadata, including any communication timeout, so that a later rejoin restores it. It then deletes the membership, decreases the recorded member count by one, and detaches the user from the guild on the main Gateway. The member is removed from guild member search when the guild has an indexed member set. +The user loses membership and disappears from guild member search. A later rejoin restores previous membership settings, including any communication timeout still in force. [Guild Member Remove](/gateway/events/#guild-member-remove) fires to the guild. A `MEMBER_KICK` entry is written to the guild's own [audit log](/http-api/guild-audit-logs/#audit-actions). The entry names the acting Admin account and has the audit reason. The write fires [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding [VIEW_AUDIT_LOG](/http-api/permissions/). @@ -620,7 +618,7 @@ The ban record names the acting Admin account as moderator. It has the expiry, t A positive deletion window queues a background job that deletes the target's matching messages after the response, which fires [Message Delete Bulk](/gateway/events/#message-delete-bulk) as deletion progresses. -[Guild Ban Add](/gateway/events/#guild-ban-add) fires to the guild. A target who was a member is then removed. That decreases the recorded member count, detaches the user from the guild on the main Gateway, removes the member from guild member search, and fires [Guild Member Remove](/gateway/events/#guild-member-remove). The ban path snapshots no membership metadata, so a communication timeout in force at the moment of the ban is not restored on a later rejoin. This operation writes no entry to the guild's own audit log, and it emits no [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). +[Guild Ban Add](/gateway/events/#guild-ban-add) fires to the guild. If the target was a member, they lose access, disappear from member search, and trigger [Guild Member Remove](/gateway/events/#guild-member-remove). A communication timeout in force at the ban is not restored on a later rejoin. This operation writes no entry to the guild's own audit log and emits no [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). One Admin audit entry is recorded with the action `ban_member`, the target type `guild_member`, and the banned user as the target. Its metadata has the guild ID, the user ID, the `delete_message_days` value, and any supplied reason and duration. @@ -911,4 +909,3 @@ The operation emits no Gateway Dispatch and records no Admin audit entry. ### Rate limit 200 requests per minute for each authenticated user, on the `admin:lookup` bucket. - diff --git a/fluxer_docs/src/content/docs/admin-api/index.mdx b/fluxer_docs/src/content/docs/admin-api/index.mdx index 12fc83985..c06a64546 100644 --- a/fluxer_docs/src/content/docs/admin-api/index.mdx +++ b/fluxer_docs/src/content/docs/admin-api/index.mdx @@ -328,7 +328,7 @@ An operation records one of the following actions. The field is a free-form stri ## Audit reasons -Fluxer reads the `X-Audit-Log-Reason` header once at the boundary, on every request to the API, Admin or not. The header is read raw and never percent-decoded, so a client that URI-encodes the value has the encoded form stored. +Send `X-Audit-Log-Reason` on operations that support an audit reason. Values are not percent-decoded, so a URI-encoded reason remains encoded in the audit log. Normalisation strips control and format characters and trims surrounding whitespace. An absent header, a blank value, and a value whose normalised length exceeds 512 characters all resolve to null. Fluxer never fails a request during this normalisation, so an over-long reason is dropped silently. diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index 4d5eae638..455ae7e7c 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -26,6 +26,8 @@ A secret is reported by a companion boolean such as `client_secret_set`. Except The complete runtime configuration of the deployment. Every configuration operation here except the limit configuration and the heap snapshot returns it. +Missing settings use the defaults documented below. Invalid stored configuration causes an error rather than silently resetting a policy. Operators can find recovery guidance under [stored instance policy](/operator/configuration/#stored-instance-policy). + ### Structure | Field | Type | Description | @@ -88,7 +90,9 @@ Admission and dispatch tuning for the Gateway cluster. | gateway_dispatch_relay_max_queue | integer | Dispatch relay queue ceiling (0-1000000, default 50000) | | voice_e2ee_scope | string | `guild_feature_only` or `platform_wide` (default `guild_feature_only`) | -Every field is present on read. A deployment that has stored nothing reports the defaults above. +Every field is present on read. An absent document or missing field uses the defaults above. + +Admin requests use `rpc_request_timeout_ms`. The legacy stored name is covered in the [operator configuration reference](/operator/configuration/#stored-instance-policy). ## Voice noise suppression configuration object @@ -111,7 +115,7 @@ The instance rollout of client-side noise suppression. [Experiments](/http-api/e | stereo_enabled | boolean | Whether a drawn client publishes a stereo microphone track (default false) | | suppression_strength | integer | Suppression strength (0-100, default 80) | -Every field is present on read. A deployment that has stored nothing reports the defaults above. +Every field is present on read. An absent document or missing field uses the defaults above. `excluded_user_ids` is applied before `included_user_ids`, so an account in both is never drawn. A `default_backend` or `guild_overrides` entry naming a backend outside `enabled_backends` is dropped from what a client is served, and the stored value is kept as written. @@ -128,7 +132,7 @@ How often a client polls [Get experiment assignments](/http-api/experiments/#get | poll_interval_seconds | integer | Seconds between client revalidations (60-86400, default 300) | | poll_jitter_percent | integer | Spread applied to each revalidation (0-50, default 15) | -Every field is present on read. A deployment that has stored nothing reports the defaults above. +Every field is present on read. An absent document or missing field uses the defaults above. Both fields are served to every account, whether or not any experiment targets that account, and neither one is versioned by `config_version`. A client that has never reached the experiments route holds the same values as built-in defaults, 300 seconds and 15 percent, so neither field reaches a client that cannot read the route. @@ -340,20 +344,22 @@ Outbound email settings, and the provider resolved from them. ## Bluesky integration object -Client identity the deployment presents to Bluesky, and the number of signing keys it stores. +Client identity the deployment presents to Bluesky, and the number of signing keys in its effective configuration. ### Structure | Field | Type | Description | | --- | --- | --- | | enabled | ?boolean | Operator override, or null for no override | -| effective_enabled | boolean | Whether the integration is in force | +| effective_enabled | boolean | Whether the integration is enabled with at least one configured signing key | | client_name | ?string | Client name presented to Bluesky | | client_uri | ?string | Client URI | | logo_uri | ?string | Client logo URI | | tos_uri | ?string | Client terms URI | | policy_uri | ?string | Client policy URI | -| key_count | integer | Number of stored signing keys | +| key_count | integer | Number of configured signing keys | + +Signing keys are write-only and must have unique identifiers. ## Instance media object @@ -365,7 +371,9 @@ Attachment retention overrides the operator has set, and the values in force. | --- | --- | --- | | attachment_decay1 | object | Nullable operator overrides plus an `effective` object of the same keys with concrete values | -1 The override keys are `enabled`, `min_size_mb`, `max_size_mb`, `max_eligible_size_mb`, `min_lifetime_days`, `max_lifetime_days`, `curve`, `renew_threshold_days`, and `renew_window_days`. Each is null when the deployment default applies, and `effective` reports the value in force. `enabled` is a boolean, `curve` is a number from 0 to 1, the `_mb` keys are positive numbers, and the `_days` keys are positive integers +1 The override keys are `enabled`, `min_size_mb`, `max_size_mb`, `max_eligible_size_mb`, `min_lifetime_days`, `max_lifetime_days`, `curve`, `renew_threshold_days`, and `renew_window_days`. Each is null when the deployment default applies, and `effective` reports the value in force. `enabled` is a boolean, `curve` is a number from 0 to 1, the `_mb` keys are positive numbers, and the `_days` keys are positive safe integers + +Conflicting maximum size or lifetime overrides are cleared and resolved from deployment defaults. The effective size range must have a finite maximum above its minimum, and retention must produce a valid expiry date. Check the returned `effective` values after an update. ## Branding asset kinds @@ -417,13 +425,13 @@ One issued registration URL together with the link an Admin hands out. ## Limit configuration response object -The stored limit configuration together with the deployment defaults and the metadata an editor needs. +The effective limit configuration together with the deployment defaults and the metadata an editor needs. ### Structure | Field | Type | Description | | --- | --- | --- | -| limit_config | [limit configuration](#limit-configuration-object) object | Stored configuration in force | +| limit_config | [limit configuration](#limit-configuration-object) object | Effective configuration on the serving node | | limit_config_json | string | The same document rendered as JSON indented by two spaces, for an editor to display | | self_hosted | boolean | Whether the deployment runs in self-hosted mode | | defaults1 | map[string, map[string, integer]] | Deployment default limits, keyed by rule identifier and then by limit key | @@ -432,7 +440,7 @@ The stored limit configuration together with the deployment defaults and the met | limit_keys | array[string] | Every [limit key](/http-api/instance/#limit-keys) in registry order | | bounds?3 | map[string, object] | Optional `min` and `max` pair for each limit key | -1 Built with the premium mode treated as `everyone`, so on a self-hosted deployment whose premium mode is `mirror` it omits the `premium` rule that is applied +1 Built for the deployment and the premium mode currently loaded by the serving node. Self-hosted `mirror` mode includes the `premium` rule 2 The keys are `messages`, `guilds`, `channels`, `expressions`, `files`, `social`, and `features` @@ -464,7 +472,7 @@ One rule in that ordered set, with the filters that scope it and the limits it s | limits | map[string, integer] | Non-negative value for each [limit key](/http-api/instance/#limit-keys) the rule sets | | modifiedFields?1 | array[string] | Limit keys whose value differs from the deployment default | -1 A rule whose identifier matches no default rule and no rule named `default` reports every key it sets. A rule with no differing key omits the field +1 Compared with the deployment default rule of the same identifier, or with the deployment's `default` rule for a custom identifier. A set key absent from that default counts as modified. A rule with no differing key omits the field. Explicit limit values remain unchanged ## Limit key metadata object @@ -550,7 +558,7 @@ Fluxer skips URL validation while the merged configuration leaves single sign-on | services? | object | Nullable `gif_enabled`, `youtube_enabled`, and `bluesky_enabled` overrides | | deferred_phone_gate?3 | object | `enabled`, `window_hours`, and `member_threshold` | -1 Setting `single_community_enabled` to true adopts the already designated guild when one still exists, and otherwise creates a community using `single_community_name` or the configured product name. On a deployment whose setup is already complete, enabling it while no guild is designated fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED`, as does enabling it when the acting Admin account cannot be resolved. Setting it to false only clears the flag and leaves the guild in place +1 Setting `single_community_enabled` to true adopts the already designated guild when one still exists. When none is designated or the designated guild was deleted, it creates a community using `single_community_name` or the configured product name. A malformed designation or other datastore lookup error fails the operation instead of creating a replacement. On a deployment whose setup is already complete, enabling it while no guild is designated fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED`, as does enabling it when the acting Admin account cannot be resolved. Setting it to false only clears the flag and leaves the guild in place 2 The setting can be changed only while `direct_messages_locked` is false, and a change attempted after the lock is set fails with 400 `INSTANCE_POLICY_TRANSITION_NOT_ALLOWED` unless the same request sets `direct_messages_locked` to false. Re-enabling direct messages sets the lock again @@ -571,7 +579,7 @@ The order is `gateway_rollout`, `voice_noise_suppression`, `experiment_delivery` ### Side effects -Fluxer validates a Gateway rollout change against the complete stored configuration and then publishes it to the Gateway cluster. A premium mode change reloads the resolved limit configuration on every node. Enabling single community mode creates the community when none is designated, and the acting Admin becomes its owner. +Gateway rollout changes apply across the cluster. Premium mode changes affect the limits in force without replacing the saved limit configuration. On self-hosted deployments, `everyone` hides premium-filtered rules. Switching back to `mirror` restores them unless an Admin has replaced the limit configuration in the meantime. Enabling single community mode creates the community when none is designated, with the acting Admin as owner. Initial setup completes on the first update that sets `app_public.setup.configured` to true from a session credential whose account holds neither `admin:authenticate` nor the wildcard. That update grants the account the wildcard Admin ACL and marks the deployment as bootstrapped. @@ -742,14 +750,16 @@ Approves or rejects one pending registration and returns the resulting [instance 1 An account that no longer exists still answers 200. The pending registration is removed, no trait is written, and no audit entry is recorded -:::caution[A decision is final] -Both decisions remove the pending registration, so the same account cannot be decided again here. Rejecting an account writes the `registration_rejected` trait. Reversing the decision means clearing that trait through [Set user traits](/admin-api/users/#set-user-traits). +:::caution[Decisions apply even without a pending entry] +Both decisions remove the pending registration, but the endpoint does not require the account to be in that list. Rejecting writes the `registration_rejected` trait. A later approval can clear it again. The trait can also be changed through [Set user traits](/admin-api/users/#set-user-traits). ::: ### Side effects Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait, and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way. +Invalid pending-registration data prevents the decision. A later failure can still leave account changes applied, so check the account and pending list before retrying. + One Admin audit entry with the action `approve_registration` or `reject_registration` targets the account, records the audit reason, and has no metadata. ### Rate limit @@ -845,7 +855,7 @@ A deployment behind a load balancer needs several attempts to reach a particular ### Side effects -The snapshot is written to a temporary file on the serving node, streamed to the caller, and deleted once the stream closes. No configuration is changed and no Admin audit entry is recorded. +No configuration is changed and no Admin audit entry is recorded. ### Rate limit diff --git a/fluxer_docs/src/content/docs/admin-api/jobs.mdx b/fluxer_docs/src/content/docs/admin-api/jobs.mdx index dbc06af0d..14eda67e4 100644 --- a/fluxer_docs/src/content/docs/admin-api/jobs.mdx +++ b/fluxer_docs/src/content/docs/admin-api/jobs.mdx @@ -1,26 +1,22 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Jobs -description: Background job ledger entries, statuses, lanes, task types, and cancellation. +description: Background job status, progress, task types, and cancellation. --- import RouteHeader from '@/components/RouteHeader.astro'; -The job ledger is Fluxer's record of the background work an instance runs. An entry reports lifecycle state, progress, attempt accounting, payload, and terminal failure text. Nothing here creates a job. The operation that needs the work queues it, such as [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job), [Create system DM broadcast](/admin-api/system-dms/#create-system-dm-broadcast), [Refresh search index](/admin-api/search-indexes/#refresh-search-index), or an [archive](/admin-api/archives/) request. +These routes list recorded background jobs and request cancellation. Create jobs through [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job), [Create system DM broadcast](/admin-api/system-dms/#create-system-dm-broadcast), [Refresh search index](/admin-api/search-indexes/#refresh-search-index), or an [archive](/admin-api/archives/) request. Reads require `jobs:view` and cancellation requires `jobs:cancel`. Every operation on this page shares the `admin:jobs:view` bucket. -:::caution[Not every task gets a ledger row] -A large share of Fluxer's background work never gets a ledger row, and that work cannot be listed, read by ID, or cancelled here. -::: - -:::note[Fan-out, embeds, and most sweeps have no ledger row] -Mention fan-out (`handleMentions` and `handleMentionChunk`), embed extraction (`extractEmbeds`), and every periodic sweep other than the blocklist feed syncs run this way. +:::note[Job updates vary by task] +Not all background work appears here, and recorded status, progress and attempts may not reflect every execution. Use the [archive routes](/admin-api/archives/) to track archive progress and failures. ::: ## Admin job object -One object is one row of the ledger. No operation on this page changes any field except `cancel_requested`. A row has a 90 day time to live, after which the job is unknown to every operation here. +One object describes one recorded job. These routes can only change `cancel_requested`. ### Structure @@ -28,47 +24,27 @@ One object is one row of the ledger. No operation on this page changes any field | --- | --- | --- | | job_id | snowflake | The ID of the job | | task_type | string | The [background job task type](#background-job-task-types) the job runs | -| status | string | The [job status](#job-statuses) the row currently holds | -| progress_current1 | ?integer | The units of work completed, or null when the task has reported none | -| progress_total1 | ?integer | The units of work expected, or null when the task has reported none or reported no total | -| progress_message1 | ?string | The step the task last reported, or null when it has reported none | -| error_message2 | ?string | The failure text recorded when the job was dead-lettered, or null otherwise | -| created_at | ISO8601 timestamp | The time the job was written to the ledger | -| started_at3 | ?ISO8601 timestamp | The time the most recent execution began, or null before the first attempt | -| completed_at | ?ISO8601 timestamp | The time the job reached a terminal state, or null while it is not terminal | -| requested_by_user_id4 | ?snowflake | The ID of the account recorded as the requester, or null when the queueing operation recorded none | -| audit_log_reason5 | ?string | The reason recorded when the job was queued, or null when none was supplied | -| jet_stream_lane6 | ?string | The [processing lane](#processing-lanes) the job runs on, or null when no lane claims the task type | -| jet_stream_seq | ?string | The stream sequence assigned when the job was published, as a decimal string | -| attempts7 | integer | The number of failed executions recorded so far | -| max_attempts8 | integer | The attempt budget captured when the job was queued | +| status | string | The latest recorded [job status](#job-statuses) | +| progress_current | ?integer | The units of work reported as complete, or null | +| progress_total | ?integer | The reported total units of work, or null | +| progress_message | ?string | The latest reported progress message, or null | +| error_message | ?string | The recorded failure reason, or null | +| created_at | ISO8601 timestamp | The time the job was recorded | +| started_at | ?ISO8601 timestamp | The latest recorded start time, or null when no start was recorded | +| completed_at | ?ISO8601 timestamp | The recorded completion, cancellation or failure time, or null | +| requested_by_user_id | ?snowflake | The recorded requester, or null | +| audit_log_reason | ?string | The recorded audit log reason, or null | +| jet_stream_lane | ?string | The assigned [processing lane](#processing-lanes), or null | +| jet_stream_seq | ?string | The recorded queue sequence as a decimal string, or null | +| attempts | integer | The recorded retry count, not a total of all executions | +| max_attempts | integer | The recorded attempt limit. Actual retry behaviour may differ | | run_at | ?ISO8601 timestamp | The earliest permitted run time, or null for an immediate job | -| cancel_requested9 | boolean | Whether cooperative cancellation has been requested | -| context_link10 | ?string | The Admin console path for the entities the job acts on, or null when the task set none | -| payload11 | ?string | The JSON rendering of the job input | -| result | ?string | Always null | +| cancel_requested | boolean | Whether cancellation has been requested | +| context_link | ?string | An Admin console path related to the job, or null | +| payload | ?string | The JSON-encoded job input, or null | +| result | ?string | The JSON-encoded result, or null | -1 The task reports progress at its own pace, and a task that never reports leaves all null for its whole run. A bulk task reports once before it starts, again after every 25 entities, and once when it finishes, except for `schedule_user_deletion`, which reports after every 10 accounts, and the file SHA bulk ban, which reports after every 50 hashes - -2 Written only when the job is dead-lettered. A single failed delivery that is redelivered leaves it null - -3 Rewritten on every delivery, so a redelivered job reports the start of its latest attempt - -4 Only [Queue bulk job](/admin-api/bulk-jobs/#queue-bulk-job) and the file SHA bulk ban record one, so a system DM broadcast, an archive, and a search index rebuild all report null - -5 The reason the queueing operation resolved from `X-Audit-Log-Reason`. A blank or too-long header resolves to null there, and the queueing operation still succeeds - -6 Present as soon as the job is queued - -7 Incremented only when an attempt fails without exhausting the lane's redelivery ceiling. A job that succeeds on its first delivery reports 0, and a dead-lettered job never counts its final failure - -8 Defaults to 5 and is recorded for reporting only. The [processing lane](#processing-lanes) bounds redelivery - -9 Set by [Cancel job](#cancel-job) while the job is queued or running, and kept on the terminal row as the record of the request. `status` reports whether the task honoured it - -10 An Admin console path such as `/guilds/{guild_id}`, not a Fluxer API route. A bulk task acting on many entities links only the first 50 identifiers - -11 Stored verbatim as JSON, so it can contain identifiers and message content. It is returned by every operation here, including the listings +Progress fields may remain null. The payload can contain identifiers and message content, including in list responses. ### Example @@ -80,13 +56,19 @@ One object is one row of the ledger. No operation on this page changes any field "progress_current": 25, "progress_total": 400, "progress_message": "Updating flags", + "error_message": null, "created_at": "2026-08-31T09:00:00.000Z", "started_at": "2026-08-31T09:00:01.000Z", + "completed_at": null, "requested_by_user_id": "1478812292088791040", + "audit_log_reason": null, "jet_stream_lane": "lifecycle", + "jet_stream_seq": "4812", "attempts": 0, "max_attempts": 5, + "run_at": null, "cancel_requested": false, + "context_link": null, "payload": "{\"user_ids\":[\"1478812292088791040\"]}", "result": null } @@ -94,15 +76,15 @@ One object is one row of the ledger. No operation on this page changes any field ## Job cursor object -The resume point [List jobs](#list-jobs) returns, split into the cursor query parameters a caller sends back. +Pass all three fields from `next_cursor` back to [List jobs](#list-jobs) using the corresponding cursor query parameters. ### Structure | Field | Type | Description | | --- | --- | --- | -| bucket_day | string | The UTC day bucket the next page resumes in, as `YYYY-MM-DD` | -| created_at | ISO8601 timestamp | The creation time the next page resumes before | -| job_id | snowflake | The job the next page resumes from | +| bucket_day | string | The cursor's UTC date as `YYYY-MM-DD` | +| created_at | ISO8601 timestamp | The cursor's creation time | +| job_id | snowflake | The cursor's job identifier | ### Example @@ -118,39 +100,28 @@ The resume point [List jobs](#list-jobs) returns, split into the cursor query pa | Value | Description | | --- | --- | -| queued | The job is written and available for processing | -| running | The job is being processed | -| succeeded | The task returned without throwing | -| cancelled1 | The task reached a cancellation checkpoint and aborted | -| deadletter | The job exhausted its lane's redelivery ceiling without succeeding | +| queued | The job was recorded and no later status has been recorded | +| running | The job was recorded as started | +| succeeded | The job was recorded as completed successfully | +| cancelled | The task honoured a cancellation request | +| deadletter | The job was recorded as failed, including a failure to queue it | -1 Only a task that aborts at the checkpoint settles here, as [Cancel job](#cancel-job) describes - -`queued` and `running` are the active statuses and the other three are terminal. [List active jobs](#list-active-jobs) returns exactly the active statuses. Cancellation is recorded only while a job holds one of them. - -A job whose queue publish fails after its ledger row is written also settles as `deadletter`. That row reports `attempts` 0, `started_at` null, and `error_message` from the publish failure. +`queued` and `running` accept cancellation requests. The other statuses are terminal. A cancellation request does not guarantee that the task will stop. ## Processing lanes -A lane is a consumer group with its own concurrency, acknowledgement deadline, and redelivery ceiling. Each task type belongs to exactly one lane, recorded on the job when it is queued. +`jet_stream_lane` identifies the job's processing group. | Value | Description | | --- | --- | -| realtime1 | Mention fan-out. Concurrency 10, 15 second deadline, 3 deliveries | -| unfurl1 | Link preview extraction. Concurrency 20, 30 second deadline, 3 deliveries | -| lifecycle1 | Account, guild, billing, moderation, archive, and bulk work. Concurrency 8, 60 second deadline, 25 deliveries | -| batch1 | Periodic sweeps, index rebuilds, and queue drains. Concurrency 12, 120 second deadline, 25 deliveries | - -1 Concurrency is per worker process and an operator can override it per lane. The delivery count bounds retries and is the ceiling after which a failing job is dead-lettered +| realtime | Mention processing | +| unfurl | Link previews | +| lifecycle | Account, guild, moderation, archive and bulk work | +| batch | Scheduled maintenance and index work | ## Background job task types -`task_type` is the registered worker task name. Every registered task is listed here with its lane. - -- `realtime` runs `handleMentions` and `handleMentionChunk`. -- `unfurl` runs `extractEmbeds`. -- `lifecycle` runs `applicationProcessDeletion`, `batchGuildAuditLogMessageDeletes`, `bulkAddGuildMembers`, `bulkBanFileShas`, `bulkDeleteSelfMessagesImmediate`, `bulkDeleteUserMessages`, `bulkDeleteUserMessagesScoped`, `bulkScheduleUserDeletion`, `bulkUpdateGuildFeatures`, `bulkUpdateSuspiciousActivityFlags`, `bulkUpdateUserFlags`, `deleteUserMessagesInGuildByTime`, `finalizeNcmecAttachmentReport`, `harvestGuildData`, `harvestUserData`, `messageShred`, `processStripeWebhook`, `reconcileUserPayments`, `revalidateUserConnections`, `sendSystemDm`, `userProcessPendingDeletion`, and `userProcessPendingDeletions`. -- `batch` runs `expireAttachments`, `flushUserActivityBuffer`, `indexChannelMessages`, `indexGuildMembers`, `processAssetDeletionQueue`, `processBunnyPurgeQueue`, `processExpiredPremiumSweep`, `processInactivityDeletions`, `processPendingBulkMessageDeletions`, `processPremiumStateReconciliationQueue`, `prunePostgresKvTtl`, `refreshSearchIndex`, `syncDiscoveryIndex`, `syncDisposableEmailDomains`, `syncFileShaBlocklists`, and `syncUrlBlocklists`. +Use the returned `task_type` value to filter [List jobs](#list-jobs). The tasks queued by [Bulk jobs](/admin-api/bulk-jobs/) are `bulkUpdateUserFlags`, `bulkUpdateSuspiciousActivityFlags`, `bulkUpdateGuildFeatures`, `bulkAddGuildMembers`, and `bulkScheduleUserDeletion`. `harvestUserData` and `harvestGuildData` build [archives](/admin-api/archives/), `sendSystemDm` delivers a [system DM broadcast](/admin-api/system-dms/), and `refreshSearchIndex` rebuilds a [search index](/admin-api/search-indexes/). @@ -158,42 +129,36 @@ The tasks queued by [Bulk jobs](/admin-api/bulk-jobs/) are `bulkUpdateUserFlags` -Returns a page of [Admin job](#admin-job-object) objects from the ledger's day buckets, newest first. Requires `jobs:view`. - -The ledger is bucketed by UTC day and each bucket is ordered by creation time descending. A request reads the current bucket, then each earlier bucket, until the page is full or the lookback window is exhausted. +Returns a page of [Admin job](#admin-job-object) objects, newest first, within the requested lookback window. Requires `jobs:view`. ### Query parameters | Field | Type | Description | | --- | --- | --- | | limit? | integer | The maximum number of jobs to return (1-200, default 50) | -| cursor_bucket_day?1 | string | The UTC day bucket to resume from as a `YYYY-MM-DD` date, taken from `next_cursor.bucket_day` | -| cursor_created_at?1 | string | The creation time to resume before as an ISO 8601 timestamp (1-64 characters), taken from `next_cursor.created_at` | -| cursor_job_id?1 | snowflake | The job to resume from, taken from `next_cursor.job_id` | -| max_lookback_days?2 | integer | The number of earlier day buckets to scan (1-60, default 14) | -| status? | string | The [job status](#job-statuses) a returned job must currently hold | -| task_type?3 | string | The [background job task type](#background-job-task-types) a returned job must run (1-128 characters) | -| requested_by_user_id? | snowflake | The ID of the account a returned job must record as its requester | +| cursor_bucket_day? | string | `next_cursor.bucket_day` as a `YYYY-MM-DD` UTC date | +| cursor_created_at? | string | `next_cursor.created_at` as an ISO 8601 timestamp (1-64 characters) | +| cursor_job_id? | snowflake | The job identifier from `next_cursor.job_id` | +| max_lookback_days? | integer | The number of days before the cursor date or today to include (1-60, default 14) | +| status? | string | Filter by recorded [job status](#job-statuses) | +| task_type? | string | Filter by [task type](#background-job-task-types) (1-128 characters) | +| requested_by_user_id? | snowflake | Filter by recorded requester | -1 The cursor parameters are one value split into parts and are supplied together. A strict subset, a `cursor_bucket_day` that is not a calendar date, or a `cursor_created_at` that is not an ISO 8601 timestamp returns 400 `INVALID_FORM_BODY`, and each rejected part is named by the `path` of an element in `errors` - -2 The scan covers the starting bucket plus this many earlier buckets, so the default reads 15. The starting bucket is the current UTC day, or the cursor's bucket when one is supplied - -3 A value naming no registered task returns an empty page with 200 +Supply all three cursor parameters together or omit all three. An incomplete or malformed cursor returns 400 `INVALID_FORM_BODY`. An unknown task type returns an empty page. ### Response body | Field | Type | Description | | --- | --- | --- | | jobs | array[[Admin job](#admin-job-object) object] | The jobs in this page | -| next_cursor | ?[job cursor](#job-cursor-object) object | The cursor for the next page, or null when the page did not fill | +| next_cursor | ?[job cursor](#job-cursor-object) object | The cursor for the next request, or null | ### Response | Status | Body | Condition | | --- | --- | --- | | 200 | response body | The job page was returned | -| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY` because the cursor is a strict subset of its parts or one part is malformed | +| 400 | [error response](/admin-api/#error-response) | `INVALID_FORM_BODY` because a query parameter is invalid | ### Rate limit @@ -203,19 +168,15 @@ The ledger is bucketed by UTC day and each bucket is ordered by creation time de -Returns every queued or running job as [Admin job](#admin-job-object) objects. Requires `jobs:view`. +Returns recorded active jobs as [Admin job](#admin-job-object) objects. Requires `jobs:view`. -:::note[Poll this operation for active work] -This listing takes no filters, takes no cursor, and is not paginated. A job leaves it when it reaches a terminal state. -::: - -A job still active after its ledger row has passed the 90 day time to live is omitted from the response. +This operation has no filters or pagination. Recorded state can lag execution. ### Response body | Field | Type | Description | | --- | --- | --- | -| jobs | array[[Admin job](#admin-job-object) object] | Every queued or running job the index holds | +| jobs | array[[Admin job](#admin-job-object) object] | The recorded active jobs | ### Response @@ -250,11 +211,9 @@ Returns one [Admin job](#admin-job-object) object. Requires `jobs:view`. | Status | Body | Condition | | --- | --- | --- | | 200 | response body | The job was returned | -| 4041 2 | `{"error": "job_not_found"}` | No ledger entry has this identifier | +| 404 | `{"error": "job_not_found"}` | No job record has this identifier | -1 This operation answers a missing job with its own single-field body, so it has no `code` and no `errors` - -2 A job queued with the ledger suppressed and a job whose row has passed its 90 day time to live are both reported this way, so 404 does not mean the work never ran +A 404 response uses this single-field body rather than the standard error response. A missing or expired record does not mean the work never ran. ### Rate limit @@ -276,9 +235,9 @@ Requests cooperative cancellation of a job and reports whether the request was r | Field | Type | Description | | --- | --- | --- | -| cancelled1 | boolean | Whether a cancellation request was recorded | +| cancelled | boolean | Whether a cancellation request was recorded | -1 False when the job is already terminal and also when no ledger entry has the identifier, so a false value does not distinguish the two. Cancelling a job that is already cancel-requested returns true again +Returns false for a missing or terminal job. Repeating a request for a queued or running job returns true. ### Response @@ -286,17 +245,9 @@ Requests cooperative cancellation of a job and reports whether the request was r | --- | --- | --- | | 200 | response body | The request was evaluated, whether or not the flag changed | -:::caution[Cancellation is cooperative and never retroactive] -The job stops only at its next checkpoint, which for a bulk task is between two entities. Every effect already applied stays in place. Most registered tasks never check for cancellation and run to completion with the flag set. -::: - -:::note[`status` reports whether the task honoured the request] -A [bulk job](/admin-api/bulk-jobs/) task and a [system DM broadcast](/admin-api/system-dms/) both abort at the checkpoint and settle as `cancelled`. A task that never checks the flag settles as it otherwise would. -::: - ### Side effects -An active job records `cancel_requested` as true and stops at its next checkpoint. Fluxer leaves a terminal job unchanged. Nothing reverses the Gateway Dispatches the job already produced. +Sets `cancel_requested` to true for a queued or running job. Only tasks that support cancellation will stop, and completed work is not undone. A status of `cancelled` confirms that the task honoured the request. ### Rate limit diff --git a/fluxer_docs/src/content/docs/admin-api/messages.mdx b/fluxer_docs/src/content/docs/admin-api/messages.mdx index 69fb81ddd..be9c2b838 100644 --- a/fluxer_docs/src/content/docs/admin-api/messages.mdx +++ b/fluxer_docs/src/content/docs/admin-api/messages.mdx @@ -269,7 +269,7 @@ There is no grace period and no restore operation. ### Side effects -The attachments the message owned are purged from storage and from the CDN, the message row is deleted, and its search index document is removed. +The message and its attachments are permanently removed, including from search results and the CDN. [Message Delete](/gateway/events/#message-delete) is sent to the guild when the channel belongs to one, and to each recipient when the channel is private. No other Dispatch is emitted, and deleting a pinned message here emits no [Channel Pins Update](/gateway/events/#channel-pins-update). @@ -431,7 +431,7 @@ The request disables the author's account and schedules that account for deletio Fluxer resolves the attachment from the live message when that message still exists and has an author, and from the frozen evidence named by `source_report_id` otherwise. Fluxer accepts the attachment only when its resolved content type begins with `image/` or `video/`. -Fluxer marks the attachment `submitting`, opens an NCMEC report, uploads the attachment bytes, submits its file details with the viewed flag set, and finishes the report. On success the attachment is marked `submitted` with the assigned report ID. On failure the opened report is retracted, the attachment is marked `failed` with the raw reason, and the request returns `NCMEC_SUBMISSION_FAILED`. A retraction that itself fails is logged and does not change the recorded state. +The attachment is `submitting` while the request runs, then `submitted` with its report ID on success. A failed submission attempts to retract the opened report, marks the attachment `failed` with the raw reason, and returns `NCMEC_SUBMISSION_FAILED`. A failed retraction is logged without changing that recorded state. When the resolved attachment has an author who has not already been enforced against, Fluxer sets the account's deleted and disabled flags, clears any temporary ban, and records the deletion reason for child sexual content. It then schedules deletion 60 days ahead, deletes every authentication session, propagates the resulting user update, and triggers a user archive for that account. diff --git a/fluxer_docs/src/content/docs/admin-api/reports.mdx b/fluxer_docs/src/content/docs/admin-api/reports.mdx index 27c11c755..8ca5641d6 100644 --- a/fluxer_docs/src/content/docs/admin-api/reports.mdx +++ b/fluxer_docs/src/content/docs/admin-api/reports.mdx @@ -154,7 +154,7 @@ Fluxer skips an entry when neither the capture nor the report records its channe ## Report message context object -A context entry is one message copied into the report when the report was filed. A later edit or deletion of the message does not update it. Fluxer copies its attachments into a separate evidence bucket, so they survive deletion of the original. +A context entry preserves a message as it was when the report was filed. Later edits or deletions do not change it, and attachment evidence survives deletion of the original. A message report captures the reported message together with at most 25 messages before it and at most 25 after it, for at most 51 entries. A message whose author account Fluxer could not read at capture time is absent from the report. @@ -216,7 +216,7 @@ A message report captures the reported message together with at most 25 messages | ncmec_report_id | ?string | The NCMEC report ID, or null when the attachment was never submitted | | ncmec_failure_reason | ?string | The failure detail, or null unless `ncmec_status` is failed | -9 A presigned URL against the report evidence bucket, valid for five minutes, so a saved response body stops resolving attachments while the evidence copy is retained +9 The URL expires after five minutes. Fetch the report again for fresh attachment links 10 An in-flight submission is stored as `submitting`, a value the registry below does not list diff --git a/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx b/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx index bce6d2f57..ef0222eed 100644 --- a/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx +++ b/fluxer_docs/src/content/docs/admin-api/search-indexes.mdx @@ -6,7 +6,7 @@ description: Administrative search index rebuilds, index names, and rebuild prog import RouteHeader from '@/components/RouteHeader.astro'; -A search index holds the documents a search reads, and a rebuild writes those documents from current data. These routes queue a rebuild and read its progress. The rebuild itself runs as the `refreshSearchIndex` background task described by [Jobs](/admin-api/jobs/). +These routes rebuild search indexes from current data and report progress. Rebuilds appear in [Jobs](/admin-api/jobs/) as `refreshSearchIndex`. Both routes require the [ACL](/admin-api/#acl-evaluation) `guild:lookup`. @@ -25,13 +25,13 @@ Both routes require the [ACL](/admin-api/#acl-evaluation) `guild:lookup`. | guild_members3 | One guild | Members of one guild, requiring `guild_id` | | favorite_memes4 | One user | Favourited [memes](/http-api/memes/) of one user, requiring `user_id` | -1 This name has no index of its own. It reads every approved listing and writes its description, category, primary language, and tags onto the existing guild documents in batches of 200 +1 Refreshes the description, category, primary language and tags of approved discovery listings without removing guilds from search -2 This name indexes no message itself. It clears the message documents of every channel in the guild, then queues one `indexChannelMessages` job for each channel +2 Clears the guild's message index and queues `indexChannelMessages` jobs for its channels. Completion of the parent job does not mean those jobs have finished -3 The rebuild clears the guild's member documents first and stamps `members_indexed_at` on the guild when it finishes +3 Clears the guild's member index first and updates `members_indexed_at` on completion -4 No worker handler is registered for this name. The queued job fails before it writes any progress record +4 Rebuilding this index is not supported. The request is accepted, but the job fails without reporting progress Every instance-wide name other than `discovery` deletes its documents before the first batch is written, so search over that index is incomplete for the whole run. @@ -88,7 +88,7 @@ Progress for one queued rebuild. The object shape is selected by `status`. 3 Rewritten on every progress report, so its value moves forward while the rebuild runs -The failure write replaces the record with the status, the index type, the error text, and the failure time, so a `failed` record has neither `total` nor `indexed`. +A failed result includes `status`, `index_type`, `error` and `failed_at`, but neither `total` nor `indexed`. ### Example @@ -146,9 +146,9 @@ Search results over the named index can be incomplete until the rebuild finishes ### Side effects -Fluxer rebuilds the named index from current data in batches of 1,000 documents. A guild-scoped rebuild does not check that the guild exists, so an unknown ID produces an empty index. +A guild-scoped rebuild for an unknown guild produces an empty index. -The job has an attempt budget of 1, so a rebuild that throws is not retried. +A failed rebuild is not retried automatically. Fluxer records one Admin audit entry with the action `queue_refresh_index`, the target type `search_index`, and the target ID `0`. Its metadata has `index_type`, `job_id`, and whichever of `guild_id` and `user_id` the request supplied. Fluxer emits no Gateway Dispatch. diff --git a/fluxer_docs/src/content/docs/admin-api/system-dms.mdx b/fluxer_docs/src/content/docs/admin-api/system-dms.mdx index 2215958f9..c060869fd 100644 --- a/fluxer_docs/src/content/docs/admin-api/system-dms.mdx +++ b/fluxer_docs/src/content/docs/admin-api/system-dms.mdx @@ -38,10 +38,10 @@ Queues one message for delivery to every supplied recipient. Requires `system_dm | Status | Body | Condition | | --- | --- | --- | | 200 | response body | The delivery job was queued | -| 500 | [error response](/admin-api/#error-response) | The ledger row or the queue message could not be written | +| 500 | [error response](/admin-api/#error-response) | The broadcast could not be scheduled | :::caution[The job continues past a failed recipient] -The job logs every recipient it cannot deliver to. A run that reaches the last recipient settles as `succeeded` whatever the failure count. +Failed deliveries do not stop the remaining recipients. A completed run reports `succeeded` even when some deliveries failed. ::: :::caution[The response has no job ID] @@ -52,9 +52,9 @@ Filter [List jobs](/admin-api/jobs/#list-jobs) by the `sendSystemDm` task type t For each recipient the job opens the direct message channel between the system account and that recipient, then sends the message. [Channel Create](/gateway/events/#channel-create) reaches a recipient only when the channel is newly created or was closed on that recipient's side. A recipient who already has the channel open observes only [Message Create](/gateway/events/#message-create). -The job opens the channel with no admission check, so a recipient who has never interacted with the system account still receives the message. It also skips direct message spam mitigation. On [Create private channel](/http-api/users/private-channels/#create-private-channel) that mitigation puts a caller holding the [SPAMMER](/http-api/users/#public-user-flags) flag into a channel only that caller sees. +Recipients need not have interacted with the system account before. Broadcasts bypass normal direct message spam restrictions. -Before each recipient the job checks for cancellation, so cancelling stops further delivery and leaves sent messages in place. The [job](/admin-api/jobs/#admin-job-object) then settles as `cancelled`. +Cancellation stops remaining deliveries and leaves sent messages in place. The [job](/admin-api/jobs/#admin-job-object) then reports `cancelled`. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `system_dm.send`, the target type `system_dm`, and the target ID `0`. Its metadata has `recipient_count` and `content_length` as decimal strings, and the content itself is not recorded. diff --git a/fluxer_docs/src/content/docs/admin-api/users.mdx b/fluxer_docs/src/content/docs/admin-api/users.mdx index fddd7f4f0..85f996785 100644 --- a/fluxer_docs/src/content/docs/admin-api/users.mdx +++ b/fluxer_docs/src/content/docs/admin-api/users.mdx @@ -250,11 +250,11 @@ The values [Clear user profile fields](#clear-user-profile-fields) accepts in `f | outgoing_request | A friend request the account has sent | | blocked | An account this account has blocked | -The first three categories are mirrored, so removing one also removes the matching row on the other account. A block has no mirror row. +Removing a friendship or friend request removes it for both accounts. Removing a block affects only the account that set it. ## Admin user session object -One entry for each authentication session of an account. A terminated session stays in the list as a tombstone, with `deleted_at` set. No operation returns the session token. +One entry for each authentication session of an account. Terminated sessions remain listed with `deleted_at` set. No operation returns the session token. ### Structure @@ -710,7 +710,7 @@ The operation accepts no request body and never clears verification, so the one -Runs the account holder's own verification resend path against the target account. Requires `user:update:email`. Returns an empty 204 response. +Requests a new verification email for the account. Requires `user:update:email`. Returns an empty 204 response. :::caution[An already verified account gets no email] An already verified account with no email reverification [suspicious activity flag](#suspicious-activity-flags) returns 204 without storing a token or sending anything. @@ -720,7 +720,7 @@ An already verified account with no email reverification [suspicious activity fl Nothing is sent when the instance email transport is disabled or when the address is marked hard bounced. Neither case changes the status code, and the audit entry is recorded either way. ::: -A per-address control, independent of the Admin buckets, permits three verification emails for each address in fifteen minutes. Fluxer charges it before creating the token, so a request that uses it up returns 429 and stores no token. +Each address can receive at most three verification emails in fifteen minutes, independently of the Admin rate limit. Further requests return 429 without issuing a token. ### Path parameters @@ -1108,7 +1108,7 @@ Additions are applied before removals, so a flag named in both arrays ends up cl ### Side effects -The stored flag bitfield is replaced with the computed value. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions, and each guild the account is a member of receives [Guild Member Update](/gateway/events/#guild-member-update) when a partial user field changed. +[User Update](/gateway/events/#user-update) is emitted to the account's own sessions. A change to publicly visible user fields also sends [Guild Member Update](/gateway/events/#guild-member-update) to the account's guilds. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_flags`, target type `user`, and the metadata keys `add_flags`, `remove_flags`, and `new_flags`. An empty array is omitted from the metadata map. @@ -1154,7 +1154,7 @@ Premium flags control badge display, the premium override, the purchase block, a ### Side effects -The stored premium flag bitfield is replaced with the computed value, and premium badge display changes for the account. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions. +Premium badge display reflects the new flags. [User Update](/gateway/events/#user-update) is emitted to the account's own sessions. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `update_premium_flags`, target type `user`, and the metadata keys `add_flags`, `remove_flags`, and `new_flags`. @@ -1414,7 +1414,7 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje -Creates or replaces a recoverable account deletion schedule and returns the resulting account. Requires `user:delete`. +Creates or replaces an account deletion schedule and returns the resulting account. Requires `user:delete`. A schedule cannot be changed once erasure starts. Fluxer raises the submitted delay to the minimum for the [deletion reason](#deletion-reasons), so a request for one day under any reason other than `USER_REQUESTED` is stored as 60 days. @@ -1452,20 +1452,19 @@ The `X-Audit-Log-Reason` value is also stored on the account as the private dele | --- | --- | --- | | 200 | response body | The deletion schedule was stored | | 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist | +| 409 | [error response](/admin-api/#error-response) | `CONFLICT`, because erasure has started or the deletion state changed during the request | -The operation accepts the acting Admin's own account and compares no ACL sets, so an Admin holding `user:delete` can schedule the deletion of a stronger Admin. Sending the operation again replaces the stored schedule outright. +The permission also allows scheduling deletion of the acting Admin or an account with broader permissions. ### Side effects -`DELETED` is added to the account flags, and `pending_deletion_at`, `deletion_reason_code`, `deletion_public_reason`, and the private deletion audit reason are stored. The account is queued for final deletion at the stored deadline, replacing any previous queue entry. Every authentication session is deleted. +The account can no longer authenticate, and its existing authentication sessions are deleted. Erasure is scheduled for the resulting deadline. Fluxer cancels a Stripe subscription on the account without proration and refunds the charge behind its latest invoice as fraudulent. A failure in that path is logged, and the deletion still applies. The account holder is emailed the deadline and the supplied `public_reason` when the account has an email address. -For every reason code other than `USER_REQUESTED`, further enforcement passes run. The first adds the account's email address to the email blocklist and marks its last active address, authorised addresses, live session addresses, and session tombstone addresses as suspicious IPs. - -The second resolves every pending report against the account, in pages of 100, and notifies each reporter through the ordinary [report](/admin-api/reports/) path. It runs only on an instance with a report search backend. Both passes log a failure and continue, so the request still succeeds. +Reasons other than `USER_REQUESTED` also trigger email and IP blocking and resolution of pending reports. These enforcement steps are best-effort and can fail without cancelling the deletion schedule. [User Update](/gateway/events/#user-update) is emitted after the sessions have already been deleted. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `schedule_deletion`, target type `user`, and the metadata keys `days` and `reason_code`. The identifier bans record their own blocklist entries, and a non-zero report pass records a second entry with action `auto_resolve_reports_on_deletion` and a metadata key `resolved_count`. @@ -1477,7 +1476,7 @@ The second resolves every pending report against the account, in pages of 100, a -Clears a pending deletion and returns the resulting account. Requires `user:delete`. +Cancels a scheduled deletion and returns the resulting account. Requires `user:delete`. Erasure cannot be cancelled once it starts. :::caution[Cancellation does not undo enforcement] Clearing the deadline permits the account to authenticate again, but it does not restore deleted sessions, reinstate a cancelled subscription, lift the email and IP blocklist entries the schedule wrote, or reopen the reports it resolved. @@ -1499,14 +1498,13 @@ Clearing the deadline permits the account to authenticate again, but it does not | Status | Body | Condition | | --- | --- | --- | -| 2001 | response body | The deletion was cancelled | +| 200 | response body | The deletion was cancelled, or no deletion was scheduled | | 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, because the account does not exist | - -1 The operation checks no precondition and never returns `NO_PENDING_DELETION`. An account with no pending deletion is accepted and has the deletion fields written as null +| 409 | [error response](/admin-api/#error-response) | `CONFLICT`, because erasure has started or the deletion state changed during the request | ### Side effects -`DELETED` and `SELF_DELETED` are both removed from the account flags, and `pending_deletion_at`, `deletion_reason_code`, `deletion_public_reason`, and the private deletion audit reason are cleared. The queued final deletion is withdrawn. +The deletion schedule and reasons are cleared, allowing the account to authenticate again unless another restriction applies. Fluxer emails the account holder when the account has an email address. The email quotes the `X-Audit-Log-Reason` value verbatim and falls back to the literal text `deletion canceled` when the header is absent or resolves to null. @@ -1722,7 +1720,7 @@ Removals run one at a time, so a failure partway through leaves the earlier remo ### Side effects -Each matching row is deleted. A `friend`, `incoming_request`, or `outgoing_request` also deletes the mirror row on the other account, while `blocked` deletes only the one row. +Friendships and friend requests are removed for both accounts. Blocks are removed only for the target account. Both parties of a mirrored removal receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the target account, and ordinary delivery from the unblocked account resumes. @@ -1764,7 +1762,7 @@ The operation addresses one category, so an account that is both a former friend ### Side effects -The row is deleted, and a `friend`, `incoming_request`, or `outgoing_request` also deletes the mirror row on the other account. +A friendship or friend request is removed for both accounts. A block is removed only for the owning account. Both parties of a mirrored removal receive [Relationship Remove](/gateway/events/#relationship-remove) naming the other account. A `blocked` removal dispatches only to the owning account. @@ -1778,7 +1776,7 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje -Lists the authentication sessions of the account, including the tombstones of terminated ones. Requires `user:list:sessions`. IP, reverse DNS, and location fields also require `user:view:ip`. +Lists the account's active and terminated authentication sessions. Requires `user:list:sessions`. IP, reverse DNS and location fields also require `user:view:ip`. :::caution[Listing sessions writes an audit entry] The `X-Audit-Log-Reason` header value is stored on the recorded entry. @@ -1855,7 +1853,7 @@ The tombstone stays visible through [List user sessions](#list-user-sessions), b ### Side effects -Every active session is deleted and a termination tombstone is written for each, so the terminated sessions remain listed with a non-null `deleted_at`. Every affected client is disconnected and must authenticate again. +Every affected client is disconnected and must authenticate again. Terminated sessions remain listed with `deleted_at` set. The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `terminate_sessions`, target type `user`, and no metadata. @@ -1973,7 +1971,7 @@ The account's TOTP secret, authenticator type set, and every multi-factor backup Counts every message attributed to the account, and queues their deletion when the request is not a dry run. Requires `message:delete_all`. -The operation walks the account's messages in pages of 200 before it answers, so the request time grows with the number of messages the account has sent. A misspelled `dry_run` parameter leaves the request a dry run. +The request counts messages before responding, so accounts with more messages take longer. A misspelled `dry_run` parameter leaves the request a dry run. The operation does not resolve the target account, so it never answers `UNKNOWN_USER`. An ID with no matching account is accepted and matches no message. @@ -2069,7 +2067,7 @@ The operation does not resolve the target account, so it never answers `UNKNOWN_ A shred deletes every matching message with its reactions, purges the attachments it owned, and removes it from search. Only a preceding [Create user archive](#create-user-archive) retains the original text. ::: -The job is queued with a single attempt, so a worker failure does not retry it. +A failed job is not retried automatically. ### Side effects @@ -2149,7 +2147,7 @@ Archive progress, download, and expiry are documented under [Archives](/admin-ap | 2001 | [archive](/admin-api/archives/#archive-object) object | The archive was queued | | 404 | [error response](/admin-api/#error-response) | `UNKNOWN_USER`, and no archive record or build job is created | -1 The status is 200 rather than 201, and the returned record is already stored with `progress_percent` at zero and `progress_step` set to `Queued` +1 The archive starts with `progress_percent` at zero and `progress_step` set to `Queued` ### Side effects diff --git a/fluxer_docs/src/content/docs/admin-api/voice.mdx b/fluxer_docs/src/content/docs/admin-api/voice.mdx index 452c706c1..9545c924d 100644 --- a/fluxer_docs/src/content/docs/admin-api/voice.mdx +++ b/fluxer_docs/src/content/docs/admin-api/voice.mdx @@ -1,7 +1,7 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Admin voice -description: Voice regions, voice servers, placement eligibility, and topology publication. +description: Manage voice regions, servers, credentials, and placement eligibility. --- import RouteHeader from '@/components/RouteHeader.astro'; @@ -10,12 +10,12 @@ A voice region is a named group of media machines, and a voice server is one mac No operation here addresses a live session, a participant, or a track. [Get voice state counts](/admin-api/gateway/#get-voice-state-counts) reports occupancy, and [List RTC regions](/http-api/channels/#list-rtc-regions) is the caller-facing view of the same regions. -:::note[A change decides placements made after the reload] -Every write takes effect once each API node reloads its topology. No write moves, disconnects, or re-places a live session. +:::note[Changes apply to new placements] +Changes take effect across the instance after a short delay. No write moves or disconnects an existing session. ::: -:::caution[Each write runs in separate steps] -A write stores the record, notifies every node to reload, then records the audit entry with the audit reason. A failure at the second or third step answers 500 with the record already stored, so read the record back before retrying. +:::caution[A failed response can follow a successful change] +A 500 response can arrive after the configuration has changed. Read it back before retrying. ::: ## Media transport @@ -44,7 +44,7 @@ With a guild present, a guild named by `allowed_guild_ids` is admitted at once a A region record has the identity a client sees, the coordinate placement measures distance from, and the eligibility fields described under [placement eligibility](#placement-eligibility). It has no capacity, no health, and no server count. -The operator supplies `id` on creation, and it is the primary key. A channel stores it as its `rtc_region`, and [Modify call region](/http-api/calls/#modify-call-region) accepts it, so changing it means creating a new region and deleting the old one. +The operator supplies `id` on creation. Channels reference it through `rtc_region`, and [Modify call region](/http-api/calls/#modify-call-region) accepts it. To change an ID, create a new region and delete the old one. ### Structure @@ -350,7 +350,7 @@ Deletes a region and every server registered in it. Requires `voice:region:delet | region_id | string | The ID of the region (1-64 characters) | :::danger[Deleting a region deletes every server in it] -The region record and every server record stored under it are removed in one batch. There is no confirmation step. The server records and their stored credentials cannot be recovered, so read the servers back first if they have to be recreated. +There is no confirmation step or recovery operation. Save the server configuration and credentials separately before deleting a region if they will be needed again. Server reads do not return credentials. ::: ### Response body @@ -370,7 +370,7 @@ The region record and every server record stored under it are removed in one bat ### Side effects -Each node reloads its topology once for the whole batch, and an operation that has to reach a deleted server afterwards fails. Sessions already placed in the region are not disconnected by the deletion itself. +Operations that need a deleted server fail once the change takes effect. Existing sessions are not disconnected by the deletion itself. One [Admin audit entry](/admin-api/#admin-audit-entry-object) with the action `delete_voice_region` and the target type `voice_region` records the region identifier and its name in metadata. No `delete_voice_server` entry is written for the servers deleted with the region. diff --git a/fluxer_docs/src/content/docs/authentication.md b/fluxer_docs/src/content/docs/authentication.md index 06fcc65ea..a35a1825d 100644 --- a/fluxer_docs/src/content/docs/authentication.md +++ b/fluxer_docs/src/content/docs/authentication.md @@ -4,86 +4,41 @@ title: Authentication description: Credential syntax, token formats, authorisation outcomes, and sudo mode. --- -An authenticated request has one credential in the `Authorization` header. Fluxer accepts these kinds. A client acting for a person sends a user session token, an application's bot sends a bot token, a client acting on a user's behalf under OAuth2 sends an access token, and the [Admin API](/admin-api/) takes an Admin API key. The kind decides who Fluxer treats as the caller and which [authorisation policy](#authorisation-outcomes) the matched operation applies. +Send one credential in the `Authorization` header. Each operation states which [credential types](#authorization-schemes) it accepts. -Fluxer returns every failure named here in the standard [error response](/http-api/#error-response) envelope. The operations that issue and revoke credentials belong to the [Authentication HTTP API](/http-api/authentication/) and the [OAuth2 HTTP API](/http-api/oauth2/). +Failures use the standard [error response](/http-api/#error-response). The [Authentication HTTP API](/http-api/authentication/) and [OAuth2 HTTP API](/http-api/oauth2/) document credential issuance and revocation. :::caution[Credential namespaces are distinct] -`Bot`, `Bearer`, and `Admin` select different validation paths. A bot token, OAuth2 access token, user session token, [upload capability](/media-proxy/upload-relay/), or [signed media path](/media-proxy/overview/) cannot substitute for another credential type. +Bot tokens, OAuth2 access tokens, user session tokens, Admin API keys, [upload capabilities](/media-proxy/upload-relay/), and [signed media paths](/media-proxy/overview/) are not interchangeable. ::: ## Credential handling -Every credential described here is a bearer secret, so whoever holds the value can act as its owner. A client MUST keep it out of logs, analytics, crash reports, source code, screenshots, and error messages, and MUST store it only while it needs the credential. A client MUST use TLS whenever the credential crosses a network it does not fully trust. +Anyone holding a credential can act as its owner. Keep credentials out of logs, analytics, crash reports and source code, and retain them only while needed. Use TLS on untrusted networks. -An `Authorization` credential MUST NOT be copied into a URL. Webhook tokens, signed media paths, and relay capabilities are in their documented URLs, so a client treats each complete URL as secret until it expires or is revoked. Redirect targets, request traces, and referrer data MUST NOT disclose those URLs to an unrelated origin. +Never put an `Authorization` credential in a URL. For webhook tokens, signed media paths and upload capabilities, treat the complete URL as a secret. Do not expose it through redirects, traces or referrers. ## Authorization header -The header value must have no leading or trailing whitespace, and a padded value never authenticates. A value beginning with `Bot `, `Bearer `, or `Admin ` selects that scheme, and the rest must be non-empty and must have no surrounding whitespace either. The prefixes match exactly, so any other spelling is not recognised as a scheme. - -A value containing no space is parsed as a bare user session token. A value containing a space without a recognised scheme prefix is invalid. - -Fluxer leaves the request unauthenticated when the credential is invalid, unknown, or unresolvable, and the matched operation's authorisation policy decides the outcome. An operation that requires a credential returns 401 `UNAUTHORIZED`. - -A user session token is sent bare, with no scheme prefix. - -```text -Authorization: flx_ZDb1GURItsMuYl1zvrgxv2qLBxyNmgNSEaWT -``` - -The value after `Bot ` is the application's snowflake, a full stop, and the secret. - -```text -Authorization: Bot 1501314428688998182.aDFplg-fYQ4of4I7dM-q9tRxkgmjslc2LSE_BTwsAUo -``` - -The value after `Bearer ` is an OAuth2 access token. - -```text -Authorization: Bearer IE867jBd9L4M0_tGI8OUOppXVezR1u6x8Yj-Lduilxg -``` - -The value after `Admin ` is an Admin API key. - -```text -Authorization: Admin fa_1508923117441703936_KaqkNax1BF3YSWHGkEPjDRKeO48jGb9F -``` +Use one of the forms below with a non-empty token and no surrounding whitespace. Scheme prefixes are case-sensitive except on the two routes noted under [Bot tokens](#bot-tokens). ### Authorisation schemes | Value | Name | Description | | --- | --- | --- | -| `Bot ` | Bot token | The token is `.`, accepted only while the application owns an active bot user and the secret is current | -| `Bearer ` | OAuth2 access token1 | The token is resolved as an OAuth2 access token. Access is limited by its application, the account that granted it, and its scopes | -| `flx_<36 alphanumeric characters>` | User session token | A header value containing no space is parsed as a user session token whatever its shape2 | -| `Admin ` | Admin API key3 | The key is read only on a route below `/v1/admin`. Effective Admin ACLs are bounded by both the key and its owning user | +| `Bot ` | Bot token | Authenticates the application's bot account | +| `Bearer ` | OAuth2 access token1 | Access granted by an account, limited to the token's scopes | +| `` | User session token | Sent without a scheme prefix | +| `Admin ` | Admin API key | Accepted only below `/v1/admin`, with permissions limited by the key and its owner | 1 One value is handled differently. `Bearer flx_` followed by 36 alphanumeric characters authenticates the user session it names -2 A bare value that resolves to no live session leaves the request unauthenticated - -3 A valid key presented on any other route is ignored and the request stays unauthenticated - ## Token formats -| Value | Name | Description | -| --- | --- | --- | -| `flx_<36 characters>` | User session token1 | The literal prefix `flx_` followed by exactly 36 characters drawn from ASCII letters and digits | -| `.` | Bot token2 | The application's decimal snowflake, a single full stop, and the base64url secret | -| `` | OAuth2 access token | A 43-character unpadded base64url secret presented with the `Bearer` scheme | -| `fa__<32 characters>` | Admin API key3 | The literal prefix `fa_`, the key's decimal [snowflake](/snowflakes/), an underscore, and 32 characters drawn from ASCII letters and digits | - -1 The token is opaque and has no client-readable claims - -2 The identifier before the full stop selects which application record to check, and only the secret after it authorises the request - -3 A key whose identifier segment is not a decimal integer is invalid - -OAuth2 access tokens, OAuth2 refresh tokens, OAuth2 authorisation codes, bot token secrets, and application client secrets are 43-character unpadded base64url values. +Treat issued tokens as opaque values. Send them unchanged with the appropriate scheme. A bot token includes its application ID and secret as `.`. :::caution[A secret is shown once] -A bot token, an Admin API key, and a client secret cannot be read back after the response that created them. The only retained fragment is the bot token preview, the first 8 characters of the secret. +A bot token, Admin API key or client secret cannot be read back after the response that created it. A bot token preview cannot authenticate a request. ::: :::note[Rotation invalidates the previous value immediately] @@ -92,29 +47,25 @@ Rotation applies to a bot token and a client secret, and rotating a bot token al ## User session tokens -A user session token authenticates an ordinary user account. The login, registration, and session exchange operations in [Authentication](/http-api/authentication/) issue it. A token that does not identify a live session leaves the request unauthenticated. The Gateway accepts a user session token in [Identify](/gateway/commands/#identify). +A user session token authenticates an ordinary user account. Login, registration, and session exchange in the [Authentication HTTP API](/http-api/authentication/) issue it. The Gateway accepts it in [Identify](/gateway/commands/#identify). -The `Authorization` header holds a single credential. A [sudo mode](#sudo-mode) proof travels separately, in the `X-Fluxer-Sudo-Mode-JWT` header, and it proves that the already resolved account recently re-verified. +A [sudo mode](#sudo-mode) proof supplements the token through the separate `X-Fluxer-Sudo-Mode-JWT` header. ## Bot tokens -A bot token is the owning application's [snowflake](/snowflakes/), a single full stop, and a secret. It is valid only while the application has an active bot user and the secret is current. - -The Gateway accepts a bot token in [Identify](/gateway/commands/#identify), and so do [`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information) and [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application). Those operations match the scheme prefix without regard to case. `GET /v1/applications/@me` requires the `Bot` prefix and returns 401 `INVALID_TOKEN` for anything else. +The Gateway accepts a bot token in [Identify](/gateway/commands/#identify). The HTTP operations [`GET /v1/gateway/bot`](/http-api/gateway/#get-gateway-information) and [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application) accept case-insensitive scheme prefixes. `GET /v1/applications/@me` specifically requires `Bot` and returns 401 `INVALID_TOKEN` for anything else. The [Gateway authentication](#gateway-authentication) section covers the other route. A bot cannot use an operation restricted to ordinary user accounts, and such an operation returns 403 `ACCESS_DENIED`. An operation in [Authentication](/http-api/authentication/) that resolves an account from its request body or token, such as login, password recovery, email verification, email revert, and IP authorisation, returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED` when that account is a bot. ## OAuth2 access tokens -An OAuth2 access token is an unprefixed secret presented with the `Bearer` scheme. `Bearer` selects OAuth2 validation. One value is handled differently: `Bearer flx_` followed by 36 alphanumeric characters authenticates the user session it names. +Every OAuth2 access token belongs to an account. Supported grants are authorisation code and refresh token. See [authorisation schemes](#authorization-schemes) for the `Bearer` syntax and user session exception. -Every access token is bound to an account. The token operation implements the authorisation code grant and the refresh token grant alone. +Only operations that explicitly support OAuth2 accept access tokens. A user operation without that support returns 403 `ACCESS_DENIED` for a valid access token. -Fluxer admits a bearer credential only on an operation that explicitly opts in. On an operation that requires a user but has not opted in, a valid bearer credential returns 403 `ACCESS_DENIED`. +Scopes apply only to OAuth2 access tokens, not to user session credentials accepted by the same operation. A bearer-only operation rejects a session token, bot token, or Admin API key with 401 `UNAUTHORIZED`. -An operation that opts in either requires the bearer credential outright or accepts a session credential and enforces the scope requirement only when the credential is a bearer token. An operation that requires the bearer credential outright and receives a valid session, bot, or Admin API key credential returns 401 `UNAUTHORIZED`. - -A missing scope returns 403 `MISSING_OAUTH_SCOPE`. An operation that names a scope names exactly one, and Fluxer matches that scope exactly against the set the token was granted. The [OAuth2 scope registry](/http-api/oauth2/#oauth2-scopes) is closed, and the authorisation code flow, token exchange, refresh, revocation, and introspection operations are specified in the [OAuth2 HTTP API](/http-api/oauth2/). +A missing scope returns 403 `MISSING_OAUTH_SCOPE`. Each operation requires its named scope exactly. The [OAuth2 HTTP API](/http-api/oauth2/) defines the supported [scopes](/http-api/oauth2/#oauth2-scopes), grants, refresh, revocation, and introspection. ### Missing OAuth2 scope body @@ -126,38 +77,36 @@ The response body has this member alongside `code` and `message`. ## Admin API keys -An Admin API key is read only on a route below `/v1/admin`, and an unknown, expired, or invalid key leaves the request unauthenticated. A valid key authenticates as the user who created it, and the request has the ACLs stored on the key. +An Admin API key authenticates as its creator on routes below `/v1/admin`. Permissions are limited by both the key and its creator. Fluxer also accepts a user session token or an OAuth2 bearer token on an Admin operation, and it accepts the bearer token only when it belongs to the built-in Admin OAuth2 application. A bearer token from any other application returns 403 `ACCESS_DENIED`. A request with a bot token returns 401 `UNAUTHORIZED`. -On every Admin request the resolved user must hold the `admin:authenticate` ACL or the wildcard, and a user without either returns 403 `MISSING_PERMISSIONS`. A key-authenticated request is checked twice, and either failure returns 403 `MISSING_ACL`. The [Admin API](/admin-api/) hub defines the complete ACL registry, the evaluation modes, the double check, and the audit contract. +Every Admin request requires the user's `admin:authenticate` ACL or wildcard, otherwise it returns 403 `MISSING_PERMISSIONS`. Key-authenticated requests also require the operation's ACLs on both the key and its owner, otherwise they return 403 `MISSING_ACL`. The [Admin API](/admin-api/) defines the ACLs and audit contract. ## Authorisation outcomes -An operation that requires a credential declares one of the authorisation policies: +Each protected operation states its authentication policy: - A user operation requires a resolved user and rejects an OAuth2 bearer credential it has not opted into. A user-only operation rejects a bot account as well. - A bot operation accepts a bot token, which resolves the application's bot account as the request identity. - An OAuth2 operation requires the `Bearer` scheme together with the scope it names. - An Admin operation requires a session, Admin OAuth2 bearer, or Admin API key credential together with the required ACLs. -No authorisation policy requires the `Bot` scheme itself. [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application) is the only operation that requires the prefix. +Only [`GET /v1/applications/@me`](/http-api/applications/#get-bot-application) requires the `Bot` prefix itself. -Fluxer still parses and resolves a credential sent to an operation that requires none. The [rate limit](/topics/rate-limits/) buckets are keyed by the resolved account, and that account can waive a [captcha](/topics/captcha/) requirement. Some operations read the resolved account or the raw header, and each states that on its own page. +A credential can affect even an unauthenticated operation. It selects account-based [rate limits](/topics/rate-limits/) and can waive a [CAPTCHA](/topics/captcha/). Each operation documents any other use of the credential. -Fluxer answers with 401 when it resolves no usable identity, and with 403 when it resolves one the operation refuses. A missing, malformed, unknown, expired, or revoked credential returns 401 `UNAUTHORIZED`. A bot token on an Admin operation and a non-bearer credential on a bearer-only operation return 401 as well. +A protected operation returns 401 `UNAUTHORIZED` for a missing, malformed, unknown, expired, or revoked credential. Bot tokens on Admin operations and non-bearer credentials on bearer-only operations also return 401. -A resolved identity that the operation refuses returns 403 `ACCESS_DENIED`. That is the outcome for a bot account on a user-only operation and for a bearer credential on an operation that did not opt into OAuth2. An Admin OAuth2 bearer credential issued to an application other than the built-in Admin application returns 403 `ACCESS_DENIED` as well. +A valid identity denied by the operation returns 403 `ACCESS_DENIED`, subject to the credential-specific exceptions above. -Other authorisation failures use `MISSING_OAUTH_SCOPE` for a missing scope, and `MISSING_ACL` or `MISSING_PERMISSIONS` for an Admin ACL failure. Fluxer sends no `WWW-Authenticate` header on a 401, so a client distinguishes the outcomes by `code` alone. +Scope and Admin permission failures use the specific codes above. A 401 has no `WWW-Authenticate` header, so clients must inspect `code`. -The [account state gate](#account-state-gates) below runs wherever the ordinary login requirement runs. +Ordinary authenticated operations also apply the [account state gates](#account-state-gates). ## Single sign-on enforcement -An instance can enforce single sign-on. Enforcement is active only while single sign-on is enabled, its configuration is ready, and enforcement is switched on. - -While enforcement is active, an operation that uses a locally held credential returns 403 `SSO_REQUIRED`. [Authentication](/http-api/authentication/) defines all of them: +An instance can enforce single sign-on once it is configured and enabled. While enforcement is active, these [authentication operations](/http-api/authentication/) return 403 `SSO_REQUIRED`: - [Register an account](/http-api/authentication/#register-an-account) and [Log in with a password](/http-api/authentication/#log-in-with-a-password). - [Get discoverable WebAuthn options](/http-api/authentication/#get-discoverable-webauthn-options) and [Authenticate with WebAuthn](/http-api/authentication/#authenticate-with-webauthn). @@ -197,31 +146,27 @@ No shared gate rejects a deleted or disabled account. Each operation that reads ## Failed authentication -An unknown, expired, revoked, or malformed credential returns 401 `UNAUTHORIZED`, and the response does not say which. A valid credential whose identity the operation resolves and refuses returns 403 `ACCESS_DENIED`, as [authorisation outcomes](#authorisation-outcomes) sets out. +Authentication errors do not distinguish between unknown, expired, revoked, or malformed credentials. See [authorisation outcomes](#authorisation-outcomes) for response codes. -Fluxer records a malformed header and a credential that resolves nothing against the originating address. An Admin API key presented outside `/v1/admin` records nothing. - -The triggers below ban an address. Fluxer bans it on the first crossing of the distinct rejected token threshold inside the tracking window. A failure score over its threshold bans the address only after the score crosses that threshold in several separate windows. The window and both thresholds are instance configuration. Fluxer never applies an automatic ban to an address it classifies as mobile. A banned address is refused before the operation runs, as [Errors](/http-api/errors/) sets out. +Repeated authentication failures can result in an IP ban. Stop retrying a rejected credential and obtain a new one. ## Sudo mode Sudo mode is a short-lived proof that the account holder recently re-verified a credential. Each operation that requires it states that on its own page, and [Multi-factor authentication](/http-api/users/mfa/#sudo-mode) defines the accepted proofs, the [sudo verification object](/http-api/users/mfa/#sudo-verification-object) fields, and the [sudo mode methods object](/http-api/users/mfa/#sudo-mode-methods-object) returned with 403 `SUDO_MODE_REQUIRED`. -A sudo proof is an HS256 JSON Web Token with the account ID as its subject, the fixed claim `type` set to `sudo`, an issue time, and an expiry five minutes after issue. A client presents it in the `X-Fluxer-Sudo-Mode-JWT` request header. An invalid, expired, or account-mismatched token produces the same response as a missing one. +A sudo proof lasts five minutes. Present it in the `X-Fluxer-Sudo-Mode-JWT` request header. An invalid, expired, or account-mismatched token produces the same response as a missing one. Fluxer issues a token only for an account holding a multi-factor authenticator, so a password-only account re-verifies for each operation that requires sudo mode. [Create WebAuthn registration options](/http-api/users/mfa/#create-webauthn-registration-options) and [Disable current account](/http-api/users/current-user/#disable-current-account) issue no token and return no header even for a multi-factor account. A bot account satisfies sudo mode immediately. So does an account that has neither a password nor a multi-factor authenticator. :::note[A sudo proof covers every account session] -The check covers only the signature, the `type` claim, the subject, and the expiry. Revoking the session that obtained a proof leaves that proof valid. +Revoking the session that obtained a proof leaves that proof valid until it expires. ::: ## Gateway authentication -`GET /v1/gateway/bot` accepts a bot token with the `Bot` prefix, with the `Bearer` prefix, or with no prefix at all, and an absent or empty header returns 401 `MISSING_AUTHORIZATION`. The operation then checks the form alone. The value must not begin with `flx_` and must have a decimal identifier before an interior full stop, so a user session token returns 401 `INVALID_AUTH_TOKEN`. +Send a bot token or user session token in the [Identify command](/gateway/commands/#identify), not an `Authorization` header. An invalid or revoked credential closes the connection with [code `4004`](/gateway/opcodes-and-close-codes/#close-codes). A missing `token` closes with `4002` and reason `Invalid identify payload`. -A value in bot token form that matches no application receives the same response as a valid bot token. The same bot token is the credential in the [Identify command](/gateway/commands/#identify). - -A user session presents its session token in Identify. The Gateway does not read the `Authorization` header. An invalid or revoked credential closes the connection with [close code `4004`](/gateway/opcodes-and-close-codes/#close-codes). An Identify payload that has no `token` closes with `4002` and reason `Invalid identify payload`. +The HTTP [Get Gateway information](/http-api/gateway/#get-gateway-information) endpoint checks only the bot token's form. A successful response does not prove the token is valid. ## Other credential surfaces diff --git a/fluxer_docs/src/content/docs/conventions.md b/fluxer_docs/src/content/docs/conventions.md deleted file mode 100644 index 5d4ebab92..000000000 --- a/fluxer_docs/src/content/docs/conventions.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -# SPDX-License-Identifier: AGPL-3.0-or-later -title: API conventions -description: Normative keywords, protocol subjects, wire table notation, and endpoint entry structure. ---- - -This page defines the notation every reference page uses. An operation that states a different rule and names the difference overrides anything here. A code example shows the contract and never overrides prose, a wire table, a registry, or a state-transition table. - -## Normative language - -MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY, and OPTIONAL have their [BCP 14](https://www.rfc-editor.org/info/bcp14) meanings only when they appear in all capitals. A keyword in any other form has its ordinary English meaning, including one that is part of an identifier such as `TWO_FACTOR_REQUIRED`. Literal wire text, including an error message template, retains its exact wording. - -Normative force does not depend on a keyword appearing. A direct statement such as "Fluxer returns HTTP 204" defines observable behaviour, and the values in wire tables, registries, algorithms, and state-transition tables are part of the contract. - -## Protocol subjects - -Each subject below has the same meaning on every reference page. - -| Subject | Description | -| --- | --- | -| Fluxer | The deployment whose behaviour this reference documents, also written as the instance | -| caller | The party whose credential, permissions, and address one operation evaluates | -| guild | A community with its own channels, roles, members, and configuration, defined by [Guilds](/http-api/guilds/) | -| session | One established Gateway connection, which is the subject of ordering, delivery, replay, and shard rules | -| user | An account represented by the user object, including a bot account | -| bot | A user account owned by an application and flagged as a bot | -| account | The subject used where a rule holds for a user and a bot alike | -| application | An OAuth2 application record that owns client credentials and at most one bot account | -| operator | The party configuring and running a deployment | -| administrator | An account acting through the Admin API under Admin ACLs | -| resource | The object family and operation set that one reference page defines | - -A Dispatch is one Gateway server-to-client event, and a command is one client-to-server message. The reference writes ordinary user or non-bot user where a rule excludes bots. - -Several of these words have an unrelated second sense. An authentication session is the stored login record defined by [Authentication](/authentication/). A voice server is the registered media machine defined by [Admin Voice](/admin-api/voice/). A guild administrator is a member holding guild permissions. - -## Wire table notation - -A wire table describes one payload, parameter set, or object under `Field`, `Type`, and `Description` columns. A field name ending in `?`, before any footnote marker, is optional. A type beginning with `?` is nullable and permits JSON `null`. A field that is both has the marker in both positions, as in the field `communication_disabled_until?` with the type `?ISO8601 timestamp`. The name written in the table cell is authoritative. - -The `Type` column uses this notation. - -| Notation | Meaning | -| --- | --- | -| `snowflake` | An unsigned decimal string defined by [Snowflakes](/snowflakes/) | -| `decimal string` | Any other unsigned integer as a JSON string, because its range exceeds what a JSON number preserves exactly | -| `array[type]` | An array of the named type | -| `map[key, value]` | A JSON object keyed by the first type with values of the second | -| `ISO8601 timestamp` | An ISO 8601 timestamp string, which is also the representation of a timestamp field unless its description names another one | -| A link followed by `object` | The object defined at that link | -| `integer string`, `base64 string` | That representation in a JSON string | -| `binary`, `file` | A multipart file part | - -A duration field's description names its unit. - -The type of a union lists its alternatives separated by a vertical bar, written as `type | type` in a table cell. A field that accepts a small fixed set of literal values lists those exact wire values in the same form, as in `emoji | sticker`. - -A superscript marker such as 1 refers to the numbered footnote written below its table or paragraph. Numbering restarts in every table. A footnote records a presence condition, gate, bound, or computed value that does not fit in a description cell. - -Bitfield tables and enumeration tables with symbolic names share the `Value`, `Name`, and `Description` columns. A table is a bitfield when every non-zero value cell holds a shift expression of the form `1 << n`. An enumeration whose values have no symbolic name uses `Value` and `Description` alone. - -A registry is closed when its page states it is complete, gives an exact count, or states that a value outside it is rejected. A value absent from a closed registry is unsupported even when its wire type could represent it. - -A state-transition table uses `Event and condition`, `Action`, and `Next state` columns. Its first cell names an event one state accepts and then the condition that selects this outcome. A state accepts an event only when it appears in that state's table or in a table the section declares for every open state. - -## Omission and null - -On a request that modifies a stored entity and accepts a subset of its fields, omitting an optional field leaves the stored value unchanged. Sending `null` for a nullable field clears it. A field that is optional but not nullable can be set or left unchanged. A field that is nullable but not optional is always present, even when its value is `null`. - -Where an operation departs from either default, it states the departure in the field's description, in a footnote, or beside its body table. A departure can run in either direction, so an operation can accept `null` without clearing and can change a stored value that the request never named. An operation can also define an empty string or an empty array as the clearing value, and a supplied array replaces the stored collection completely. - -[Modify meme](/http-api/memes/#modify-meme) accepts this body, which leaves the stored tags unchanged, clears the alt text, and sets the name: - -```json -{ - "alt_text": null, - "name": "party horn" -} -``` - -On a response, the page that owns a field states what an absent field means. That is commonly that the operation did not fill it, that the object variant does not own it, or that its value has been cleared. An absent field is not the same as a present field whose value is `null`. - -## Endpoint entries - -An operation entry opens with its method and path. The operation's path parameter table defines each braced segment in the path, such as `{guild_id}`, under the same name without the braces. - -The method and path can be followed by capability labels. Only the labels shown apply, and a route whose only authorisation is a signed path or a capability URL has none. - -| Label | Meaning | -| --- | --- | -| `Unauthenticated` | The operation accepts a request with no credential | -| `Bot` | The operation accepts a bot token | -| `Audit reason` | The operation reads the `X-Audit-Log-Reason` request header | -| `MFA` | Multi-factor authentication or an elevated [sudo session](/http-api/users/mfa/#sudo-mode) can be required, under the condition the operation states | - -A label that is an [OAuth2 scope](/http-api/oauth2/#oauth2-scopes) name means the operation accepts an OAuth2 bearer credential and requires that scope of a bearer caller. - -Prose then states the contract. The subsections that apply follow it in this order: - -1. `Limitations`, which lists the preconditions an operation stacks, one trigger to a bullet. -2. `Path parameters`. -3. `Query parameters`. -4. `Request headers`. -5. The request body, under `JSON body`, `Form body`, `Multipart body`, or `Request body`. -6. Any subsection the surface defines for itself. -7. `Response body`. -8. `Response`. -9. `Response headers`, which states a header the operation sets for itself. -10. `Side effects`. -11. `Rate limit`, which states the bucket the operation draws on. - -Subsections that do not apply are omitted. An object that a page defines has its own field table under `Structure`. - -A response table uses `Status`, `Body`, and `Condition` columns. A `Body` cell uses the same type notation as a wire table, names `empty` where the response has no body, and names `response body` where the preceding subsection defines it. A response table has no header column. The shared contract is defined once under [standard response headers](/http-api/#standard-response-headers), and a header an operation sets for itself is stated in prose under the operation. - -## Describing behaviour - -A present-tense statement about Fluxer states an observable contract. An internal storage, service, queue, or worker detail appears only where it determines an observable ordering rule, durability guarantee, limit, timeout, error, or security boundary. - -Each failure names the value a client branches on. A human-readable message can be localised, so a client matches only the machine-readable value its surface defines. - -- An HTTP API or Admin API failure names its status and its stable error `code`. -- An OAuth2 protocol failure answers with the RFC 6749 envelope, which has no Fluxer `code` and is matched on its `error` value. -- A Media Proxy failure has a plain-text reason phrase and no machine-readable code, so a client branches on its HTTP status. -- A WebSocket failure names its close code and, where the protocol defines one, the exact close reason. - -## Limits and bounds - -Unless a page states otherwise, a limit is inclusive. A bound written as less than or before excludes the stated value. - -An array bound counts elements. A string bound names its unit, written as characters, UTF-8 bytes, UTF-16 code units, or grapheme clusters, and a bound that names more than one unit applies all of them. A bound on an encoded value states whether it applies before or after decoding. - -## Examples and notes - -A `json` example shows one valid or representative wire value, and a `text` example shows an expression or a string form. Every identifier, token, hash, and host in an example is made up, and an example host uses the reserved `example.com` domain. - -Inside inline code, angle brackets mark descriptive placeholder text standing for a real value, as in `Bot ` or `attachment://`. An ellipsis inside a JSON string value leaves out content the example does not need to show. Where inline code quotes an exact response body, the brackets are part of the literal value. - -A note states a consequence or a relationship to another rule. A caution states a detail a client would otherwise get wrong. That covers a contract that breaks the opposite assumption, a value the client must keep confidential, and an effect that cannot be undone. A danger is reserved for an outcome that destroys stored data, files an external report, revokes every credential on an account, or discloses a credential no later operation can return. All are binding. - -## Independent protocol surfaces - -The HTTP API and the Gateway each own an error registry. The Media Proxy API owns none because its failures have no code. The Gateway alone defines opcodes and close codes. - -The Admin API is a privileged namespace inside the HTTP API. It shares the `/v1` prefix, the request and response framing, the error envelope, and the standard response headers. It adds its own credential policy, ACL registry, audit contract, and rate limit registry. - -An identifier other than a [snowflake](/snowflakes/) reaches a second surface only where that surface names it. The Media Proxy API does that for the upload capability and the asset hash the HTTP API issues. diff --git a/fluxer_docs/src/content/docs/gateway/commands.md b/fluxer_docs/src/content/docs/gateway/commands.md index 7e7c360b8..cc1de5c61 100644 --- a/fluxer_docs/src/content/docs/gateway/commands.md +++ b/fluxer_docs/src/content/docs/gateway/commands.md @@ -4,7 +4,7 @@ title: Client commands description: Every main Gateway client command, its payload, bounds, result, and close behaviour. --- -A client command is a payload a client sends to Fluxer over the [Gateway](/gateway/overview/) WebSocket. Each one has an integer [opcode](/gateway/opcodes-and-close-codes/#opcodes) in `op` and the command data in `d`. Fluxer answers with a [Dispatch](/gateway/events/) event, a close frame, or nothing at all. +Clients send commands over the [Gateway](/gateway/overview/) WebSocket, with an integer [opcode](/gateway/opcodes-and-close-codes/#opcodes) in `op` and the command data in `d`. A command can produce a [Dispatch](/gateway/events/), a close frame, or no response. Except for [Heartbeat](#heartbeat), [Identify](#identify), and [Resume](#resume), every command needs an authenticated session. Sending one too early closes with `4003` and reason `Not authenticated`. Heartbeat and Resume are accepted in any open state, and Identify is accepted only while the connection is unauthenticated. @@ -53,7 +53,7 @@ Opcode `1` has the last Dispatch sequence the client processed, or `null` before Before authentication the Gateway accepts any `d` value and sends Opcode `11`. A payload with no `d` key closes with `4001` and reason `Unknown opcode`. -Once a session exists, a `d` that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. Every integer is accepted. A sequence below the one the session has already acknowledged leaves the acknowledged sequence unchanged. Any other integer becomes the acknowledged sequence and trims every retained Dispatch at or below it from the replay buffer. +Once authenticated, a `d` that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. Every integer is accepted, but acknowledgements never move backwards. Dispatches at or below the highest acknowledged sequence are no longer available for Resume. When the session ends, Fluxer sends Opcode `9` with `d: false`, after which heartbeats are acknowledged again. A heartbeat that arrives in the short window between the session ending and that frame closes with `4007`. @@ -77,7 +77,7 @@ Opcode `2` authenticates and creates a new session. 2 Names are upper-cased and deduplicated. See [Event filtering](/gateway/event-filtering/) for the exact suppression rule -3 That guild is marked active and already synced when the session connects to it, so it delivers active traffic without a [Lazy Request](#lazy-request) and sends no [Guild Sync](/gateway/events/#guild-sync). Fluxer discards a value that is not a canonical decimal Snowflake string, and the Identify still succeeds +3 The guild delivers active traffic without a [Lazy Request](#lazy-request) and sends no initial [Guild Sync](/gateway/events/#guild-sync). A value that is not a canonical decimal Snowflake string is ignored without failing Identify 4 `shard_count` is an integer from 1 through 16,384, and `shard_id` is an integer that is at least 0 and below `shard_count` @@ -207,7 +207,7 @@ An unknown or expired session produces Opcode `9` with `d: false` and leaves the A successful Resume replays every retained Dispatch strictly above `seq` in order and finishes with [Resumed](/gateway/events/#resumed). It also replaces the session's socket, and the displaced socket receives Opcode `7` followed by a close. -Fluxer processes Resume in any authentication state. A socket that already has a session attached still processes a Resume, and the named session takes the attached session's place. Send Resume only on a fresh socket. +Send Resume only on a fresh socket. It is also accepted on an authenticated socket, where it replaces the attached session. ## Presence Update @@ -298,7 +298,7 @@ The command has no `session_id` field. The current Gateway session is the member Joining or replacing a grant produces [Voice Server Update](/gateway/events/#voice-server-update) with the token and endpoint for the media connection, and [Voice State Update](/gateway/events/#voice-state-update) for every session that can see the channel. -The first two updates in a rolling one-second window are processed immediately. Later updates enter a [per-session queue](/gateway/limits-and-rate-limits/#connection-and-command-rate-limits) that holds at most 64 commands and drains one command every 500 ms. A newer update replaces an older queued update for the same `guild_id` and `connection_id` pair, and a full queue discards its oldest entry before accepting the new one. +The first two updates in a rolling one-second window take effect immediately. Later updates can be delayed or replaced by newer updates for the same `guild_id` and `connection_id`. See [command rate limits](/gateway/limits-and-rate-limits/#connection-and-command-rate-limits) for the timing and capacity bounds. ## Request Guild Members @@ -403,7 +403,7 @@ Opcode `14` sets the per-guild subscriptions that decide member list, typing, an 1 Both are Booleans when present. Any other value drops the rest of the command silently, without a close and without a result -2 A coalesced subscription waits 100 ms before Fluxer applies it, and ranges arriving inside that window merge into the ranges already buffered for the same channel. A request that has no ranges for a channel discards the ranges already buffered for it +2 Subscriptions may be combined over a 100 ms window. Ranges sent during that window are merged for the same channel, and an empty range list clears its pending ranges Fluxer applies each option only when its key is present, in the order `active`, `sync`, `member_list_channels`, `members`, `typing`. @@ -411,8 +411,6 @@ Marking a guild active changes how much traffic it produces, and [Event filterin `member_list_channels` maps a channel ID to a list of `[start, end]` ranges. A range needs `start` at least 0, `end` at least `start`, `end` at most 100,000, and `end - start` at most 99. Ranges that fail those bounds are dropped, and each channel keeps at most the first 10 that pass. A channel key that is not a Snowflake is skipped. -Fluxer applies a subscription at once when the guild has no coalescing window open, its buffer is empty, and the channel's member list is already built. That request opens the window. Fluxer buffers it as applied and does not apply it a second time when the window closes. Every other subscription waits out the window, including one for a channel whose member list is not built yet and one arriving while the window is open. - `VIEW_CHANNEL` and `VIEW_CHANNEL_MEMBERS` together control the member list subscription, and both are evaluated for each channel separately. A channel the session cannot view, or can view without holding `VIEW_CHANNEL_MEMBERS` there, receives no [Guild Member List Update](/gateway/events/#guild-member-list-update) while its siblings subscribe normally. Subscribing a channel to at least one range drops the session's other member list subscriptions in that guild, so one session holds at most one member list per guild. @@ -442,7 +440,7 @@ Opcode `15` requests current count records. Entries that are not positive Snowflakes are dropped. A guild the session is not connected to is skipped. A `nonce` outside the length bound is omitted from the result. The command never closes the connection. -Results arrive in one [Guild Counts Update](/gateway/events/#guild-counts-update). Each guild is queried with a 2,000 ms deadline under an overall 3,000 ms batch deadline, so a slow guild is omitted from the result. +Results arrive in one [Guild Counts Update](/gateway/events/#guild-counts-update). The request has a 3,000 ms deadline, and a guild that does not answer within 2,000 ms is omitted. ## Request Channel Member Counts diff --git a/fluxer_docs/src/content/docs/gateway/event-filtering.md b/fluxer_docs/src/content/docs/gateway/event-filtering.md index fc928dca7..1518244be 100644 --- a/fluxer_docs/src/content/docs/gateway/event-filtering.md +++ b/fluxer_docs/src/content/docs/gateway/event-filtering.md @@ -1,10 +1,10 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Event filtering -description: The gates a Dispatch passes before it reaches a socket, and the client controls. +description: Event visibility, guild subscriptions, ignored events, and sharding. --- -A [Dispatch](/gateway/events/) is one event Fluxer sends to a connected client. Each one passes independent gates on its way to a socket, and a client shapes its traffic with [Lazy Request](/gateway/commands/#lazy-request) subscriptions and the [Identify](/gateway/commands/#identify) `ignored_events` list. +A client controls [Dispatch](/gateway/events/) traffic with [Lazy Request](/gateway/commands/#lazy-request) subscriptions and the [Identify](/gateway/commands/#identify) `ignored_events` list. Guild availability, permissions and sharding also restrict delivery. Fluxer has no `intents` field, no intent close code, and no privileged-intent approval. A client ported from a protocol that uses intents replaces its intent mask with those mechanisms. @@ -31,21 +31,19 @@ The guild resolves each event to one of these recipient sets. | --- | --- | --- | | Channel-scoped | [Channel Create](/gateway/events/#channel-create), [Channel Update](/gateway/events/#channel-update), [Channel Delete](/gateway/events/#channel-delete)1, [Message Create](/gateway/events/#message-create), [Message Delete Bulk](/gateway/events/#message-delete-bulk), [Typing Start](/gateway/events/#typing-start), [Channel Pins Update](/gateway/events/#channel-pins-update), [Webhooks Update](/gateway/events/#webhooks-update) | Sessions that can view the channel | | Message-access filtered | [Message Update](/gateway/events/#message-update), [Message Delete](/gateway/events/#message-delete), [Message Reaction Add](/gateway/events/#message-reaction-add), [Message Reaction Remove](/gateway/events/#message-reaction-remove), [Message Reaction Remove All](/gateway/events/#message-reaction-remove-all), [Message Reaction Remove Emoji](/gateway/events/#message-reaction-remove-emoji) | Sessions that can view the channel and can access that message | -| Invite | [Invite Create](/gateway/events/#invite-create), [Invite Delete](/gateway/events/#invite-delete) | Sessions holding `MANAGE_CHANNELS` on the invite's channel2 | +| Invite | [Invite Create](/gateway/events/#invite-create), [Invite Delete](/gateway/events/#invite-delete) | Sessions holding `MANAGE_CHANNELS` on the invite's channel | | Audit log | [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) | Sessions holding `VIEW_AUDIT_LOG` in the guild | | Guild-wide | Everything else | Every session connected to the guild | 1 Channel Delete is filtered against the guild state as it was before the deletion, so the session that could see the channel is the session that learns it is gone -2 The channel is read from the payload's `channel_id`, and from a nested `channel.id` when that field is absent. An invite payload with neither field reaches no session - Every one of those sets excludes a session that has not yet received the guild's initial state. Channel visibility is `VIEW_CHANNEL` on the channel, plus extensions. A category is visible when at least one of its children is visible. A user with a live voice connection in a channel keeps virtual access to it whenever the channel would otherwise stop being visible. That covers a role or overwrite change removing `VIEW_CHANNEL`, and a move into a channel the user cannot view. Virtual access is keyed by user, so it applies to every session of that user. It is dropped when the user's voice connection to the channel ends. Message access is `READ_MESSAGE_HISTORY` on the channel. Without that permission a session still receives events for messages newer than the guild's message history cutoff. A guild that sets no cutoff offers no such fallback, so a session without `READ_MESSAGE_HISTORY` receives none of the message-access filtered events there. -[Channel Update Bulk](/gateway/events/#channel-update-bulk) is filtered twice. The guild chooses the recipient set guild-wide, and then each recipient's copy of the payload keeps only the channels that recipient can view. A recipient left with an empty `channels` array receives no Dispatch at all. +[Channel Update Bulk](/gateway/events/#channel-update-bulk) contains only channels the recipient can view. If none are visible, no event is sent. [Voice State Update](/gateway/events/#voice-state-update) takes its own path. The guild sends it straight to the sessions that can view the channel the voice state names, or, when the state names no channel, the channel the user just left. A voice state with no connection ID is not broadcast at all. @@ -102,8 +100,6 @@ The override applies to every session, including a bot session. A bot suppresses A session that no longer shares a viewable channel with the subject is dropped from that subject's subscriber set, so a client that regains access MUST resend `members` to restore delivery. Each `members` array replaces the session's previous subscription set for that guild. -A session holds a presence back in the cases below. It holds every presence that arrives before [Ready](/gateway/events/#ready), and releases the queue once it has dispatched Ready. A held presence whose subject already appears in the Ready `presences` array is dropped, and the session sends the rest in one burst. When Ready has not been dispatched within 10,000 milliseconds of session start, a fallback timer releases the queue. After that the session holds a guild presence whose `guild_id` names a guild it is not connected to, and an account-scoped presence for a user that is neither a friend nor a recipient of a group direct message it belongs to. - A bot session holds no friend or group direct message presence subscriptions, so a bot receives a presence through this guild path alone. ## Ignored events @@ -127,10 +123,6 @@ A suppressed Dispatch consumes no sequence number, so a client MUST NOT expect a The list is fixed for the lifetime of the session. Changing it requires a new Identify. -:::note[The guild computes an ignored event anyway] -`ignored_events` reduces socket traffic and replay pressure. -::: - ## The shard filter A session that identified with a `shard` pair whose `shard_id` is not 0 drops every Dispatch that does not name a guild. A Dispatch names a guild through a non-empty `guild_id`, or through a non-empty `id` on a [Guild Create](/gateway/events/#guild-create), [Guild Update](/gateway/events/#guild-update), [Guild Delete](/gateway/events/#guild-delete), or [Guild Sync](/gateway/events/#guild-sync) payload. [Rate Limited](/gateway/events/#rate-limited), [Guild Counts Update](/gateway/events/#guild-counts-update), and [Channel Member Counts Update](/gateway/events/#channel-member-counts-update) answer a command the session sent, and each passes the gate whatever its payload holds. diff --git a/fluxer_docs/src/content/docs/gateway/events.md b/fluxer_docs/src/content/docs/gateway/events.md index 3a4ae7f70..87d4d6d90 100644 --- a/fluxer_docs/src/content/docs/gateway/events.md +++ b/fluxer_docs/src/content/docs/gateway/events.md @@ -4,7 +4,7 @@ title: Gateway events description: Every main Gateway Dispatch event, its payload, its delivery scope, and its replay behaviour. --- -A Dispatch is a message from the [Gateway](/gateway/overview/) that tells a client something happened, such as a new message arriving or a member joining a guild. Every Dispatch has [Opcode](/gateway/opcodes-and-close-codes/#opcodes) 0, the name of the event, and the event's data. [Event filtering](/gateway/event-filtering/) defines the gates each one passes on its way to a socket. +A Dispatch reports a change or command result through the [Gateway](/gateway/overview/). It has [opcode](/gateway/opcodes-and-close-codes/#opcodes) 0, an event name, and a payload. ## Dispatch envelope @@ -28,7 +28,7 @@ A Dispatch is a message from the [Gateway](/gateway/overview/) that tells a clie ## Dispatch delivery -A guild-scoped Dispatch is filtered by guild availability, then by channel visibility and permissions, then by the session's active or passive state, and finally by the session-level shard filter and `ignored_events` list. An account-scoped Dispatch is subject only to the session-level filters. [Event filtering](/gateway/event-filtering/) defines each gate. +[Event filtering](/gateway/event-filtering/) defines delivery by guild availability, channel visibility, permissions, and session settings. Account-scoped Dispatches use only the session-level filters. Most guild-scoped Dispatches have a `guild_id` string. [Guild Create](#guild-create) and [Guild Sync](#guild-sync) identify the guild as `id`, and so does every [Guild Delete](#guild-delete) other than the one the guild itself dispatches when the guild is deleted. [Guild Counts Update](#guild-counts-update) and [Channel Member Counts Update](#channel-member-counts-update) have no top-level `guild_id`, and each entry in their `counts` array has its own. @@ -265,7 +265,7 @@ That budget admits one unfiltered member request per bot account and guild every The current user's account record changed. The payload is the complete [user object](/http-api/users/#user-object) in its private representation. -Fluxer also pushes the change to every account that holds a presence subscription for this user. Those accounts receive it as a [Presence Update](#presence-update) whose `user` is the new representation, so a username or avatar change reaches them without a second lookup. Fluxer sends none of those when the user's published presence is `offline`. +Presence subscribers receive the updated `user` in a [Presence Update](#presence-update), unless the user's published status is `offline`. ### USER_SETTINGS_UPDATE @@ -484,7 +484,7 @@ One operation changed several channels together, most often a reorder. | guild_id | snowflake | Guild the channels belong to | | channels | array[[channel](/http-api/channels/#channel-object) object] | Every changed channel in its complete updated representation | -Fluxer trims each recipient's copy of `channels` to the channels that recipient can view, so two sessions in the same guild can receive different arrays from one operation. A recipient whose trimmed array would be empty receives no Dispatch at all and consumes no sequence number. +Each recipient sees only channels they can view. An empty result produces no Dispatch and consumes no sequence number. ### CHANNEL_DELETE @@ -663,9 +663,9 @@ One visible presence changed. The payload is a [presence object](#presence-objec A session receives a presence for a friend, for a recipient of a group direct message it belongs to, and for a guild member it subscribed to through the `members` array of a [Lazy Request](/gateway/commands/#lazy-request). A bot session holds no friend or group direct message subscription, so the guild path is the only one that reaches it. -The session holds every Presence Update from Identify until it dispatches [Ready](#ready), so a client never receives a presence before its initial state arrives. Right after Ready the session drops every held presence for a subject the Ready `presences` array already covers and releases the rest in one burst. When Ready has not been dispatched 10,000 ms after Identify, the session releases the queue anyway. +Presence Updates normally follow [Ready](#ready). The initial Presence Update burst omits users already included in Ready. If Ready takes more than 10,000 ms, Presence Updates can arrive before it. -After the burst a Dispatch is held again when the session cannot place it. The session holds a guild-scoped Presence Update while it is not connected to the guild in `guild_id`. It holds an account-scoped one while the subject is neither a friend nor a recipient of a group direct message the session belongs to. A later [Relationship Add](#relationship-add), [Relationship Update](#relationship-update), [Channel Create](#channel-create), [Channel Update](#channel-update), or [Channel Recipient Add](#channel-recipient-add) naming the same account releases the held Dispatch. The hold queue keeps at most 2,048 entries and drops the oldest, so a held presence that is never placed is eventually discarded. +Later presences can be delayed until their guild, relationship, or group direct message becomes visible. They may arrive after [Relationship Add](#relationship-add), [Relationship Update](#relationship-update), [Channel Create](#channel-create), [Channel Update](#channel-update), or [Channel Recipient Add](#channel-recipient-add). Delivery is not guaranteed for a subject that never becomes visible. #### Presence object @@ -709,7 +709,7 @@ Every entry has the batch's guild context. A client MUST treat each entry as if ### PASSIVE_UPDATES -A passive session in a guild with more than 250 members receives the changes it would otherwise have missed. The guild runs the cycle every 30,000 ms and sends nothing when the cycle finds no change. +A passive session in a guild with more than 250 members receives missed changes every 30,000 ms. No event is sent when nothing changed. | Field | Type | Description | | --- | --- | --- | @@ -813,7 +813,7 @@ Neither `id` nor `animated` is ever null. A Unicode reaction omits both, so a cl ### MESSAGE_REACTION_ADD_MANY -A session that set the `DEBOUNCE_MESSAGE_REACTIONS` [session flag](/gateway/commands/#session-flags) merges a run of reaction additions in a private channel into one Dispatch. A reaction in a guild channel is never merged and arrives as its own [Message Reaction Add](#message-reaction-add). The session opens a 650 ms window on the first addition and sends the merged Dispatch when the window closes. The window holds at most 512 additions and drops the oldest beyond that. +With the `DEBOUNCE_MESSAGE_REACTIONS` [session flag](/gateway/commands/#session-flags), private-channel additions are grouped over 650 ms and delivered together. Each window keeps the latest 512 additions. Guild-channel additions always arrive as individual [Message Reaction Add](#message-reaction-add) events. | Field | Type | Description | | --- | --- | --- | @@ -822,7 +822,7 @@ A session that set the `DEBOUNCE_MESSAGE_REACTIONS` [session flag](/gateway/comm | guild_id?1 | snowflake | Guild the channel belongs to | | reactions | array[[reaction addition object](#reaction-addition-object)] | The merged additions, in arrival order | -1 Taken from the first addition of the group. The window is per session and groups its additions by guild, channel, and message when it closes, so each group is one Dispatch and every addition in `reactions` is on the message these fields name +1 Every addition in `reactions` belongs to this message. A window covering several messages produces a separate Dispatch for each When the window closes holding exactly one addition, the session sends [Message Reaction Add](#message-reaction-add) instead. A session without the flag receives one Message Reaction Add per addition. @@ -849,7 +849,7 @@ One user's reaction was removed from a message. Exclusion works exactly as it does for [Message Reaction Add](#message-reaction-add), so the acting session is dropped in a guild channel and kept in a private one. -For a debouncing session, a removal that matches a still-buffered addition by message, user, and emoji removes both, so neither reaches the socket. +With reaction debouncing enabled, an addition removed within the same window produces neither event for that message, user, and emoji. ### MESSAGE_REACTION_REMOVE_ALL @@ -993,11 +993,11 @@ A private channel call began, or became visible in the session's initial state. | recipients?2 | array[snowflake] | Every recipient of the channel | | created_at?2 | integer | Unix milliseconds when the call was opened | -1 Each entry also has the `region_id` and `server_id` fields a [Voice State Update](#voice-state-update) omits. Both are internal routing identity, and a client MUST NOT depend on them +1 Entries also include `region_id` and `server_id`, which clients MUST ignore -2 Present only when the session pulled the call's state for itself +2 Present on initial or recovered call state -A session pulls the call's state for itself in the cases below. The first is the Call Create it receives shortly after [Ready](#ready) for a private channel that already has a call. The second is the Call Create that reattaches the session to a call it lost, whether or not that loss produced a [Call Delete](#call-delete). +Initial call state arrives shortly after [Ready](#ready). Recovered state can arrive after a lost call connection, whether or not the client received [Call Delete](#call-delete). Recipients are every recipient of the channel, whether or not they joined the call. The same set receives [Call Update](#call-update) and [Call Delete](#call-delete). @@ -1005,7 +1005,7 @@ Recipients are every recipient of the channel, whether or not they joined the ca The ringing set, participant roster, or region of an active call changed. The payload has the same structure as [Call Create](#call-create) without `recipients` and `created_at`. -A Call Update is sent only when the computed payload differs from the last one the call published, so an operation with no visible effect produces nothing. +An operation with no visible effect produces no Call Update. ### CALL_DELETE @@ -1018,7 +1018,7 @@ A call ended, or became unavailable. 1 Absent when the call ended -With `unavailable: true` the call became unreachable. The session schedules a reattach 1,000 ms later and retries with backoff up to 15 times. A successful reattach delivers a fresh [Call Create](#call-create) with `recipients` and `created_at`, and an exhausted one delivers nothing further. A client MUST hold the call in a placeholder state. +With `unavailable: true`, the client MUST retain a placeholder for the call. If it becomes available again, a fresh [Call Create](#call-create) includes `recipients` and `created_at`. Recovery is not guaranteed. ## Count response events @@ -1067,6 +1067,4 @@ A channel the session cannot view, and a channel on which it lacks `VIEW_CHANNEL Every resource object named on this page has the representation defined by the [HTTP API](/http-api/). A Dispatch payload with a resource object has the same fields, with the guild-scoped events adding `guild_id` and the message and reaction events adding `member`. -The reductions below are specific to the Gateway and appear nowhere in the HTTP API. [Ready](#ready) strips `user` from each relationship and from each guild member and moves those accounts into its `users` array. The `member` added to a message event has its own `user` removed, and the account is in the message's `author`. A client MUST resolve those accounts from the surrounding payload. - -Every Dispatch payload also drops the fields the Gateway keeps for its own indexing: `recipient_ids`, `role_index`, `channel_index`, `member_role_index`, `role_perms_cache`, and `overwrite_perms_cache`. +These reductions are specific to the Gateway and appear nowhere in the HTTP API. [Ready](#ready) strips `user` from each relationship and from each guild member and moves those accounts into its `users` array. The `member` added to a message event has its own `user` removed, and the account is in the message's `author`. A client MUST resolve those accounts from the surrounding payload. diff --git a/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md b/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md index 1584227fd..8da4f3e37 100644 --- a/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md +++ b/fluxer_docs/src/content/docs/gateway/limits-and-rate-limits.md @@ -14,7 +14,7 @@ A compressed message that decompresses past 10 MiB closes with `4002` and reason ## Session lifecycle -Hello advertises a heartbeat interval of 41,250 ms. The Gateway checks the heartbeat state every 13,750 ms. When 37,125 ms have elapsed since the last acknowledgement it sends Opcode `1` and waits. If no new acknowledgement is accepted before the elapsed time passes 45,000 ms, the connection closes with `4009` and reason `Heartbeat timeout`. +Hello advertises a heartbeat interval of 41,250 ms. The Gateway checks the heartbeat state every 13,750 ms. When 37,125 ms have elapsed since the last acknowledgement it sends Opcode `1`, and a client answers it immediately. If no new acknowledgement is accepted before the elapsed time passes 45,000 ms, the connection closes with `4009` and reason `Heartbeat timeout`. There is no separate authentication deadline. The heartbeat rule alone closes a socket that never authenticates. @@ -24,12 +24,8 @@ One user credential holds at most 100 live sessions. A further Identify closes w Shard counts run from 1 through 16,384. One bot shard covers at most 2,500 guilds. A malformed shard pair closes with `4010` for every credential. A bot assignment above the guild ceiling closes with `4011` and reason `Sharding required`. A user session is never refused for its guild count. -One Gateway node admits 512 concurrent session starts by default, which an operator configures as `max_concurrent_session_starts` in the Gateway rollout config. Setting `session_rollout_percentage` to zero pauses session starts entirely, and a value below 100 admits only that share. - -`session_rollout_mode` decides which share. The default `modulo` hashes the account ID, so one account is admitted or refused consistently at a given percentage. The alternative `random` draws once per admission attempt, and the percentage is a share of session starts. One account can be admitted on one attempt and refused on the next. - -:::note[A refused session start is held and retried] -The Gateway refuses a session start for draining, capacity, paused starts, the rollout percentage, or a failed backend RPC. It keeps the Identify payload and retries after a jittered 1,000 ms to 1,999 ms delay until it succeeds. A rollout config change retries it immediately. +:::note[Session creation can be delayed] +During maintenance, a rollout or temporary capacity limits, an Identify can remain pending without a response. Continue heartbeating while waiting for Ready. ::: ## Session start limit @@ -38,23 +34,11 @@ The Gateway refuses a session start for draining, capacity, paused starts, the r ## Connection and command rate limits -A Gateway node running with `FLUXER_DISABLE_RATE_LIMITS` set to `1`, `true`, or `TRUE` disables these budgets together: - -- Connection payload budget -- Session payload budget -- Source IP payload budget -- Source IP connection ceiling -- Presence Update budget -- Voice State Update queue -- Source IP Identify budget -- Per-user session count -- 30-second complete member list budget - -The figures below are the enforced defaults. +The figures below are the defaults. An operator can disable these limits. One WebSocket accepts 600 client payloads in a rolling 60-second window. One authenticated session accepts 600 client payloads in each fixed 60-second bucket. One source IP address accepts 6,000 client payloads in each fixed 60-second bucket. Exceeding any of these budgets closes the current connection with `4008` and reason `Rate limited`. -Fluxer evaluates the payload budgets before any command-specific budget. The session budget is skipped while the connection is unauthenticated. +The session budget applies only after authentication. One source IP address holds 256 concurrent Gateway WebSockets. A further connection closes with `4008` and reason `Too many connections` before Hello is sent. @@ -86,7 +70,7 @@ Both byte bounds measure in-memory size, not wire bytes, so both figures are app [Guild Members Chunk](/gateway/events/#guild-members-chunk) is delivered live and is never retained for Resume, whatever its size. -[Guild Sync](/gateway/events/#guild-sync) and [Guild Member List Update](/gateway/events/#guild-member-list-update) are delivered with a sequence and never retained. Those two and Guild Members Chunk are the only events excluded by name. Every other Dispatch is retained, including the pre-encoded guild fan-out that broadcasts one event to every eligible session. A replay is therefore a subset of the sequence range it covers, so replay frames have sequence gaps. +[Guild Sync](/gateway/events/#guild-sync) and [Guild Member List Update](/gateway/events/#guild-member-list-update) are also excluded from replay. A replay can therefore have sequence gaps even within its retention window. A heartbeat with a sequence discards every retained Dispatch at or below that sequence and records it as the acknowledged sequence. A client MUST acknowledge only a sequence whose events it has finished processing. A heartbeat with `null`, and a heartbeat with a sequence below the acknowledged sequence, change nothing. Resume neither acknowledges nor evicts. A client that never heartbeats with a sequence keeps its full window until the count or byte bound evicts from the front. @@ -94,9 +78,7 @@ Resume closes with `4007` on a `seq` below the acknowledged sequence, and on a ` An eviction from the count or byte bound raises a replay floor to the highest sequence it dropped. A Resume with a `seq` below that floor produces Opcode `9` with `d: false` and no close, so a client that reconnects long after it fell behind Identifies again. -Each session holds at most 2,048 entries in its presence hold queue and discards the oldest when full. [Presence Update](/gateway/events/#presence-update) states when the queue is held and released. - -Fluxer drops a presence cast to a session process whose mailbox already holds more than 5,000 messages. A session that cannot keep up sheds presence casts and stays connected. +Presence updates can be dropped under load without closing the connection. ## Command payload limits diff --git a/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md b/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md index b7ca2b97b..518e3a351 100644 --- a/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md +++ b/fluxer_docs/src/content/docs/gateway/opcodes-and-close-codes.md @@ -52,7 +52,7 @@ An opcode is the number that names a [Gateway payload](/gateway/overview/#gatewa 1 [Resumed](/gateway/events/#resumed) has the session's current sequence without advancing it, and a replayed Dispatch keeps the sequence it was first sent with -2 The advertised interval is 41,250 ms, so the threshold is 37,125 ms and the acknowledgement deadline is 45,000 ms. Both are tested on a 13,750 ms timer and acted on at the first tick at or after them +2 Answer a server heartbeat request immediately, in addition to the regular schedule 3 A client that sends Opcode 5 or 12 gets the same close as a client that sent an undefined opcode @@ -120,8 +120,6 @@ Code 4006 is unassigned, and no code above 4012 is defined. [Event filtering](/g 2 A Resume that fails token verification leaves the named session in place for the rest of its retention window, so a later Resume with the owning token still recovers it. An Identify that fails token verification leaves nothing to recover -A session fenced for a cluster handoff stops on this node once its state has been copied to the node taking it over. Fluxer holds that copy for 120,000 ms and sweeps it on a 10,000 ms timer. The next Resume consumes it. - `Resumable` describes only whether an already established session can still be recovered with [Resume](/gateway/commands/#resume). The 60,000 ms retention window and the bounded replay buffer described in [Limits and rate limits](/gateway/limits-and-rate-limits/#replay-and-backpressure) apply unchanged. :::caution[Reconnecting unchanged reproduces `4004`, `4010`, and `4012`] @@ -147,30 +145,30 @@ The Gateway sends an exact reason string with every application close. | Reason | Code | Cause | | --- | --- | --- | -| Invalid API version | 4012 | The `v` connection parameter is absent or is not `1` | -| Too many connections | 4008 | The source IP already holds 256 concurrent Gateway connections | -| Encode failed | 4002 | Hello could not be encoded1 | -| Compression failed: zstd-stream | 4002 | Hello could not be compressed on a connection that negotiated zstd1 | -| Payload too large | 4002 | An inbound message exceeded 4,096 bytes on the wire, or exceeded 4,096 bytes after decompression | -| Decompression failed | 4002 | An inbound compressed message could not be decompressed | -| Decode failed | 4002 | The payload is not valid JSON, or decodes to something other than an object | -| Invalid payload | 4002 | The decoded object has no `op` | -| Rate limited | 4008 | The connection, source IP, or user client payload budget was exceeded | -| Unknown opcode | 4001 | The opcode is undefined, is a server opcode, or the payload has no `d` | -| Not authenticated | 4003 | An authenticated command arrived before a session was attached | -| Already authenticated | 4005 | Identify arrived while a session was attached, or with no `d` | -| Invalid identify payload | 4002 | Identify is missing `token` or `properties`, or a field has the wrong type | -| Invalid shard | 4010 | The Identify `shard` value is not a valid `[shard_id, shard_count]` pair | -| Sharding required | 4011 | More than 2,500 guilds resolve to one bot session after the shard filter | -| Too many sessions | 4008 | The user already holds 100 live Gateway sessions2 | -| Invalid token | 4004 | The Identify token is invalid, or the Resume token does not own the named session | -| Failed to start session | 4000 | Session creation returned a failure the Gateway does not classify3 | -| Invalid resume payload | 4002 | Resume is not an object, or `token` or `session_id` is missing or is not a string, or `seq` is missing or is not an integer | -| Invalid sequence | 4007 | Heartbeat or Resume supplied a sequence outside the bounds stated in [Invalid sequence](#invalid-sequence) | -| Invalid presence payload | 4002 | Presence Update is not an object, has no `status`, or has a `status` string that is not a known value | -| Session unavailable | 4000 | The retained session could not be reached while Resume was in progress | -| Session drain requested; reconnect to continue | 4000 | Opcode `7` was sent immediately before the close4 | -| Heartbeat timeout | 4009 | No new heartbeat acknowledgement was accepted within 45,000 ms of the preceding acknowledgement | +| `Invalid API version` | 4012 | The `v` connection parameter is absent or is not `1` | +| `Too many connections` | 4008 | The source IP already holds 256 concurrent Gateway connections | +| `Encode failed` | 4002 | Hello could not be encoded1 | +| `Compression failed: zstd-stream` | 4002 | Hello could not be compressed on a connection that negotiated zstd1 | +| `Payload too large` | 4002 | An inbound message exceeded 4,096 bytes on the wire, or exceeded 4,096 bytes after decompression | +| `Decompression failed` | 4002 | An inbound compressed message could not be decompressed | +| `Decode failed` | 4002 | The payload is not valid JSON, or decodes to something other than an object | +| `Invalid payload` | 4002 | The decoded object has no `op` | +| `Rate limited` | 4008 | The connection, source IP, or user client payload budget was exceeded | +| `Unknown opcode` | 4001 | The opcode is undefined, is a server opcode, or the payload has no `d` | +| `Not authenticated` | 4003 | An authenticated command arrived before a session was attached | +| `Already authenticated` | 4005 | Identify arrived while a session was attached, or with no `d` | +| `Invalid identify payload` | 4002 | Identify is missing `token` or `properties`, or a field has the wrong type | +| `Invalid shard` | 4010 | The Identify `shard` value is not a valid `[shard_id, shard_count]` pair | +| `Sharding required` | 4011 | More than 2,500 guilds resolve to one bot session after the shard filter | +| `Too many sessions` | 4008 | The user already holds 100 live Gateway sessions2 | +| `Invalid token` | 4004 | The Identify token is invalid, or the Resume token does not own the named session | +| `Failed to start session` | 4000 | Session creation returned a failure the Gateway does not classify3 | +| `Invalid resume payload` | 4002 | Resume is not an object, or `token` or `session_id` is missing or is not a string, or `seq` is missing or is not an integer | +| `Invalid sequence` | 4007 | Heartbeat or Resume supplied a sequence outside the bounds stated in [Invalid sequence](#invalid-sequence) | +| `Invalid presence payload` | 4002 | Presence Update is not an object, has no `status`, or has a `status` string that is not a known value | +| `Session unavailable` | 4000 | The retained session could not be reached while Resume was in progress | +| `Session drain requested; reconnect to continue` | 4000 | Opcode `7` was sent immediately before the close4 | +| `Heartbeat timeout` | 4009 | No new heartbeat acknowledgement was accepted within 45,000 ms of the preceding acknowledgement | 1 Both reasons are produced only while the Hello frame is being written. A later outbound frame that cannot be encoded or compressed is dropped and the connection stays open diff --git a/fluxer_docs/src/content/docs/gateway/overview.md b/fluxer_docs/src/content/docs/gateway/overview.md index c27ede154..c0a0b97b4 100644 --- a/fluxer_docs/src/content/docs/gateway/overview.md +++ b/fluxer_docs/src/content/docs/gateway/overview.md @@ -34,7 +34,7 @@ A client opens the socket, waits for Hello, sends Identify, and then heartbeats } ``` -`token` and `properties` are the only required fields. The token is the raw account or bot token, with no HTTP authentication prefix, so a bot sends it without the `Bot ` prefix the HTTP API requires. [Client commands](/gateway/commands/#identify) defines the rest. Everything the server sends after Ready is a [Dispatch](/gateway/events/#dispatch-delivery), which is one event payload with its name in `t` and its data in `d`. +Only `token` and `properties` are required. Send the raw user or bot token without an HTTP authentication prefix. See [Identify](/gateway/commands/#identify) for optional fields. Account and guild updates arrive as [Dispatches](/gateway/events/#dispatch-delivery), with the event name in `t` and its data in `d`. ## Protocol version @@ -104,7 +104,7 @@ Snowflakes are decimal strings. See [Snowflakes](/snowflakes/) for the identifie `zstd-stream` is a continuous stream in both directions. A client MUST feed every server frame to the same decompressor in arrival order and produce every client frame from the same compressor. -The server compresses at level 3. One WebSocket message has exactly one Gateway payload. +One WebSocket message has exactly one Gateway payload. Hello is already compressed on a connection that negotiated `zstd-stream`, so the first frame such a connection receives is a binary frame. @@ -223,11 +223,7 @@ Opcode 1 is accepted before and after authentication. Before a session exists it The server answers with Opcode 11 Heartbeat ACK, which has no `d`. Once a session is attached, a `d` value that is neither `null` nor an integer closes with `4007` and reason `Invalid sequence`. A session that does not answer within 5,000 ms closes with the same code and reason. -The Gateway also runs its own timer, which ticks every 13,750 ms. On the first tick at or after 37,125 ms since the last acknowledgement, it sends Opcode 1 with `d: null` and marks the connection as awaiting an acknowledgement. On the first tick more than 45,000 ms after that acknowledgement, it closes with `4009` and reason `Heartbeat timeout`. A connection that never answers is therefore asked at 41,250 ms and closed at 55,000 ms. - -A client MUST answer the server's Opcode 1 with its own Opcode 1. - -An accepted client Heartbeat resets the elapsed time and clears the awaiting state. The server's own Opcode 1 does neither, so the deadline keeps running from the last client Heartbeat. +The server can request an immediate heartbeat with Opcode 1 and `d: null`. Answer it with your own Opcode 1. Continue sending heartbeats at the advertised interval. A connection that misses the heartbeat deadline closes with `4009` and reason `Heartbeat timeout`. A heartbeat with a sequence permanently trims every retained Dispatch at or below that sequence from the replay buffer and records it as the acknowledged sequence. A client MUST send the sequence it has processed, because a later Resume from a lower sequence closes with `4007`. diff --git a/fluxer_docs/src/content/docs/http-api/applications.mdx b/fluxer_docs/src/content/docs/http-api/applications.mdx index ca99d95a0..51b2cca56 100644 --- a/fluxer_docs/src/content/docs/http-api/applications.mdx +++ b/fluxer_docs/src/content/docs/http-api/applications.mdx @@ -15,7 +15,7 @@ Every route except [Get public application](#get-public-application) requires a The sudo-gated routes read `X-Fluxer-Sudo-Mode-JWT`. A valid proof for the authenticated account satisfies [sudo mode](/http-api/users/mfa/#sudo-mode) on its own, and Fluxer echoes it back in the response header. Fluxer issues no token to an account with no authenticator, so that account proves sudo mode with `password` in the body. :::caution[Credentials are shown once] -A client secret is returned only by [Create application](#create-application) and [Reset client secret](#reset-client-secret), and a [bot token](/authentication/#token-formats) only by [Create application](#create-application) and [Reset bot token](#reset-bot-token). Both are stored as one-way hashes. +A client secret is returned only by [Create application](#create-application) and [Reset client secret](#reset-client-secret), and a [bot token](/authentication/#token-formats) only by [Create application](#create-application) and [Reset bot token](#reset-bot-token). ::: ## Rate limits @@ -89,7 +89,7 @@ The bot account an application owns. [Create application](#create-application) c A bot token is the application ID, a full stop, and an opaque secret of 32 random bytes encoded as base64url. ::: -Fluxer identifies the application from the token itself and rejects an invalid secret without saying whether the application exists. The built-in `Fluxer Admin` application owns no bot account, so a token bearing its ID is always rejected. +An invalid bot token returns the same error whether or not its application exists. ## Public application object @@ -140,7 +140,7 @@ The application as the bot token that application issued sees it. 1 Read from the bot account, so it changes with [Update bot profile](#update-bot-profile), and it is null when the application has no bot account -2 Fluxer stores no application signing key +2 No application signing key is available 3 The request fails with 401 `INVALID_TOKEN` when the owning account no longer exists @@ -359,7 +359,7 @@ Returns one [application](#application-object) object. Only the owner can read i | 403 | [error response](/http-api/#error-response) | The credential is a bot or bearer token, or the caller does not own the application (`ACCESS_DENIED`) | | 404 | [error response](/http-api/#error-response) | The application does not exist (`UNKNOWN_APPLICATION`) | -Fluxer reports existence before ownership, so an application the caller does not own returns 403 `ACCESS_DENIED` and never 404. +An existing application owned by another account returns 403 `ACCESS_DENIED`. ### Rate limit @@ -586,11 +586,9 @@ A 204 response means the removal has finished. Repeating the request afterwards ### Side effects -Fluxer reassigns every message the bot authored to a newly created placeholder account with the deleted-account representation. It creates that placeholder only when the bot authored at least one message. The bot's tag is released for reuse. +The bot's messages remain attributed to a deleted account, and its tag becomes available for reuse. The bot is removed from every guild and its profile is anonymised as `DeletedUser#0000`, with the global name `Deleted User`. -The bot is removed from every guild it is a member of. The bot account record is retained and rewritten to the deleted-account representation. That representation has the username `DeletedUser`, the global name `Deleted User`, and the discriminator `0000`. It has no email, no password, no authenticators, no avatar, banner, biography, pronouns, accent colour, timezone, or date of birth, and only the deleted flag is set. Fluxer then deletes the application record. - -The bot token and the client secret stop authenticating as soon as the application record is gone. No further token exchange, refresh, introspection, or revocation succeeds for the application. Access and refresh tokens already issued to it are not deleted, and an outstanding access token keeps authenticating a scope-gated route until it expires. [Get current OAuth2 authorisation](/http-api/oauth2/#get-current-oauth2-authorisation) and [Get OAuth2 user information](/http-api/oauth2/#get-oauth2-user-information) resolve the application as well, so both return 401 `INVALID_TOKEN` for that token. +The bot token and client secret stop working. Token exchange, refresh, introspection, and revocation also fail. An existing access token can still authenticate a scope-gated route until it expires, but [Get current OAuth2 authorisation](/http-api/oauth2/#get-current-oauth2-authorisation) and [Get OAuth2 user information](/http-api/oauth2/#get-oauth2-user-information) return 401 `INVALID_TOKEN`. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/authentication.mdx b/fluxer_docs/src/content/docs/http-api/authentication.mdx index 35418730c..13be0e534 100644 --- a/fluxer_docs/src/content/docs/http-api/authentication.mdx +++ b/fluxer_docs/src/content/docs/http-api/authentication.mdx @@ -25,7 +25,7 @@ An invalid JSON shape returns 400 `INVALID_FORM_BODY` with [validation error obj When SSO is both enabled and enforced, every local authentication operation returns 403 `SSO_REQUIRED`. The SSO status route, SSO start and completion, logout, both session routes, and every handoff route stay available under enforcement. Every other route on this page is a local authentication operation. :::caution[SSO enforcement precedes every other check] -The enforcement check runs before CAPTCHA verification, before the route rate limit, before authentication, and before body validation. An enforced instance therefore answers a malformed or unauthenticated local authentication request with 403 `SSO_REQUIRED`. +An enforced instance returns 403 `SSO_REQUIRED` even for a malformed or unauthenticated local authentication request. ::: A successful sign-in creates one authentication session and issues its token. Fluxer sets no ceiling on live sessions and evicts none when a further session starts, so an account holds one session per sign-in until it revokes them through [terminate authentication sessions](#terminate-authentication-sessions). @@ -177,9 +177,9 @@ One live session belonging to the authenticated account. The listing is ordered 1 This digest is the only session identifier the API exposes, and it is the exact value accepted by [terminate authentication sessions](#terminate-authentication-sessions) -2 The field is false on every entry +2 Exactly one entry is true, the session whose token authenticated the request -A client that needs to identify its own session MUST compare the base64url SHA-256 digest of its own token against `id_hash`. +A client that needs to identify its own session reads the entry whose `current` is true. ## Client info object @@ -197,15 +197,7 @@ The parsed device metadata recorded for a session or a pending handoff. 1 A native or Electron client reports null, as does a session created by an unparseable request. A handoff omits the member entirely -Fluxer recognises a native Fluxer client from a `User-Agent` beginning `Fluxer Android`, `Fluxer iOS`, `Fluxer Linux`, `Fluxer Desktop`, or `Fluxer Client`. - -- A native client's `platform` is the instance product name followed by the resolved operating system, with `Lite` inserted before the operating system for a non-mobile one. A native client that resolves no operating system reports the product name followed by `Lite`, so `platform` is never null for a native client. -- An Electron client's `platform` is the product name followed by the resolved operating system, and the product name alone when no operating system resolves. -- Every other client is parsed from the recorded `User-Agent`, and its `platform` is the browser name, or the operating system name when no browser resolves, and null when neither resolves. - -Fluxer resolves `device` from the recorded `User-Agent` and, for a native client, from the operating system reported through `X-Fluxer-Client-Properties`. A native or Electron client is `mobile` only when the resolved operating system is iOS or Android. Every other client is `mobile` when the parsed platform type is mobile or tablet, and `desktop` otherwise. - -A session reports a null `location` when the recorded IP address resolves to no location. A handoff always reports the object, and each of its members is null when that component is unavailable. For a session, an address with a known region and country but no known city reports the region as `city`, the country as `region`, and null as `country`. +Device metadata is inferred from the client's request headers. Treat `platform` as a display label, not a stable application identifier. Location is approximate and can be unavailable. A session then reports null `location`, while a handoff reports an object with null members. ## Client location object @@ -327,9 +319,7 @@ The operation stays available while SSO is enforced. It shares the `auth:sso:sta -Creates one SSO state with PKCE and nonce material. Authentication is not required. Returns an [SSO start](#sso-start-object) object. - -The returned `authorization_url` is the configured provider authorisation endpoint with `response_type` set to `code`, the configured `client_id` and `scope`, the resolved `redirect_uri`, the returned `state`, and a newly generated `nonce`. It also has a `code_challenge` computed as the base64url SHA-256 digest of the state's code verifier, with `code_challenge_method` set to `S256`. +Starts a single sign-on flow. Authentication is not required. Returns an [SSO start](#sso-start-object) object. Send the user to `authorization_url` unchanged. ### JSON body @@ -355,7 +345,7 @@ The returned `authorization_url` is the configured provider authorisation endpoi ### Side effects -The returned state can be completed once and lives for ten minutes. It binds the PKCE verifier, nonce, redirect, and callback URI to the flow. No account state changes. No Gateway Dispatch is emitted. +The returned state can be completed once and expires after ten minutes. Starting the flow changes no account state and emits no Gateway Dispatch. ### Rate limit @@ -365,7 +355,7 @@ The returned state can be completed once and lives for ten minutes. It binds the -Consumes the SSO state, exchanges the authorisation code, verifies the identity token against the provider key set and the bound nonce, resolves or provisions the account, and creates a user session. Authentication is not required. Returns an [SSO completion response](#sso-completion-response-object). +Completes a single sign-on flow and signs in or creates the linked account. Authentication is not required. Returns an [SSO completion response](#sso-completion-response-object). ### JSON body @@ -405,7 +395,7 @@ A verified provider email that adopts a bot account returns 403 `BOT_USER_AUTH_S ### Side effects -Fluxer consumes the SSO state exactly once, before it attempts the provider exchange. A failed exchange still spends the state. A client MUST then start a fresh flow. On first sign-in, the provider identity becomes exclusive to the new account. The account starts with a verified email, no password, no authenticator, and its default settings. Provisioning joins no guild and accepts no invite, so no [Guild Member Add](/gateway/events/#guild-member-add) is emitted. +A failed completion requires a fresh SSO flow. A newly created account has a verified email, no local password or authenticator, and default settings. SSO registration accepts no invite and joins no guild. Approval mode registration instead returns 403 `REGISTRATION_PENDING_APPROVAL` and creates no session. Otherwise the operation creates one authentication session and clears an expired temporary suspension. @@ -540,7 +530,7 @@ When the instance has disabled new-IP authorisation or sends no email, Fluxer au Account policy can return 403 `REGISTRATION_PENDING_APPROVAL`, 403 `REGISTRATION_REJECTED`, 403 `ACCOUNT_SUSPENDED_TEMPORARILY`, or 403 `ACCOUNT_SUSPENDED_PERMANENTLY`. A bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`. :::note[A verified password reactivates the account] -On an account the user had disabled, a correct password clears the disabled state. On an account with a pending self-scheduled deletion, a correct password cancels that deletion. Both happen during the login, before any second factor is requested, with no confirmation step and no reactivation ticket. +On an account the user had disabled, a correct password clears the disabled state. It also cancels a self-scheduled deletion if erasure has not started. Both happen before any second factor is requested, without a separate confirmation step. Once erasure starts, login returns `ACCOUNT_SUSPENDED_PERMANENTLY`. ::: The login clears an expired temporary suspension the same way, but returns the 403 of a live temporary or permanent administrator suspension without clearing it. @@ -552,6 +542,7 @@ The login clears an expired temporary suspension the same way, but returns the 4 | 200 | [authentication token response](#authentication-token-response) \| [MFA challenge response](#mfa-challenge-response) | The credentials are accepted | | 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, email, or password is invalid | | 403 | [error response](/http-api/#error-response) | Account policy, SSO enforcement, or IP authorisation prevents the login | +| 409 | [error response](/http-api/#error-response) | `CONFLICT`, because the deletion state changed while cancelling a self-scheduled deletion | | 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, email, or IP bucket denies the request | | 500 | [error response](/http-api/#error-response) | Unexpected internal failure occurs | | 503 | [error response](/http-api/#error-response) | The instance is at its in-flight request ceiling | @@ -675,7 +666,7 @@ This operation shares the MFA attempt allowances of [complete login with TOTP](# ### Side effects -Success consumes the challenge and the ticket, records the credential's new signature counter and last use, and creates one authentication session. No Gateway Dispatch is emitted. +Success consumes the challenge and ticket and creates one authentication session. No Gateway Dispatch is emitted. ### Rate limit @@ -740,7 +731,7 @@ A bot account returns 403 `BOT_USER_AUTH_SESSION_CREATION_DENIED`. An account th ### Side effects -Success consumes the challenge, records the credential's new signature counter and last use, clears an expired temporary suspension, and creates one authentication session. No Gateway Dispatch is emitted. +Success consumes the challenge, clears an expired temporary suspension, and creates one authentication session. No Gateway Dispatch is emitted. ### Rate limit @@ -949,11 +940,7 @@ Consumes a valid reset token and replaces the account password, then issues a ne An unknown or already consumed token, and a token bound to a deleted account, return the field code `INVALID_OR_EXPIRED_RESET_TOKEN`. A live temporary suspension returns 403 `ACCOUNT_SUSPENDED_TEMPORARILY`. A permanent suspension returns the field code `INVALID_OR_EXPIRED_RESET_TOKEN` and never 403 `ACCOUNT_SUSPENDED_PERMANENTLY`. A bot account returns 403 `BOT_USER_AUTH_ENDPOINT_ACCESS_DENIED`. -:::note[The breached-password check is an outbound request] -The corpus is the public Pwned Passwords range service at `https://api.pwnedpasswords.com`, which is not configurable. Fluxer sends only the first five hexadecimal characters of the candidate password's SHA-1 digest to that service. -::: - -Fluxer treats the password as unbreached when the service returns a non-success status, returns a malformed body, or does not answer inside five seconds. The same check runs during [registration](#register-an-account) and [email reversion](#revert-an-email-change). +Passwords are checked against a breached-password corpus, as they are during [registration](#register-an-account) and [email reversion](#revert-an-email-change). :::caution[Resetting a password revokes every existing session] A successful reset terminates every authentication session on the account, including the one any other device is holding. It then issues one fresh session. An account with a second factor instead receives an MFA ticket, and the session follows the factor. @@ -1018,11 +1005,9 @@ Fluxer checks the replacement password against the same breached-password corpus ### Side effects -The operation restores the previous email address and marks it verified, sets the replacement password, and terminates every authentication session. Fluxer also clears the TOTP secret and every authenticator type, and deletes every MFA backup code and every registered WebAuthn credential. It then clears the authorised IP address set and authorises the requesting client IP address alone. +The previous email address becomes verified and the replacement password takes effect. All other sessions, second factors and authorised IP addresses are removed. Only the requesting IP address remains authorised. -Every terminated session loses its Gateway session as [shared behaviour](#shared-behaviour) states. [User Update](/gateway/events/#user-update) is emitted for the account, and Fluxer records the contact change. - -The operation then creates one new authentication session and returns it. +The account receives [User Update](/gateway/events/#user-update). Existing Gateway sessions end as described under [shared behaviour](#shared-behaviour), and the response returns one new authentication session. ### Rate limit @@ -1083,7 +1068,7 @@ The caller MUST send a valid sudo token in the `X-Fluxer-Sudo-Mode-JWT` header, A `totp` method reads `mfa_code` as an authenticator code and accepts an unconsumed backup code in its place. A `webauthn` method reads `webauthn_response` and `webauthn_challenge` together, and it accepts only a challenge that was issued for the sudo context. :::caution[The caller can delete its own session] -The operation deletes exactly the sessions identified by the supplied `id_hash` values. `current` is false on every entry, so a client that wants to keep its own session MUST omit its own `id_hash`. Revoking the calling credential still returns 204. +The operation deletes exactly the sessions identified by the supplied `id_hash` values. A client that wants to keep its own session MUST omit the `id_hash` of the entry whose `current` is true. Revoking the calling credential still returns 204. ::: Missing or unusable proof returns 403 `SUDO_MODE_REQUIRED`, whose error body has top-level `has_mfa` and `methods` members, and `methods` reports whether `totp` and `webauthn` are available. @@ -1322,7 +1307,7 @@ A code that is not exactly 12 characters from the handoff alphabet after hyphens ### Side effects -A successful lookup consumes one of the code's three lookups and records the code as inspected, which is the state [complete desktop handoff](#complete-desktop-handoff) requires. That record lasts for the remaining life of the code, and only completion and cancellation remove it. An expired result records one failed attempt against the client IP address. No account state changes. No Gateway Dispatch is emitted. +A successful lookup consumes one of the code's three lookups and permits [complete desktop handoff](#complete-desktop-handoff). An expired result counts as a failed attempt. No account state changes or Gateway Dispatch occur. ### Rate limit @@ -1354,7 +1339,7 @@ Fluxer reads that token from the `Authorization` header, or from the body `token Sending the token in `token` puts a live credential into a JSON payload. A client SHOULD use the `Authorization` header and omit `token`. ::: -A token that resolves to no live session returns 401 `INVALID_TOKEN`. A code that is not exactly 12 characters from the handoff alphabet after hyphens and whitespace are removed returns 400 `INVALID_HANDOFF_CODE`. A code that has never been inspected through [get desktop handoff information](#get-desktop-handoff-information) returns that same error code. So do an unknown or already completed code and a request from a client IP address that has exhausted its failed-attempt allowance. A code whose five minutes have elapsed is discarded with its handoff and returns `INVALID_HANDOFF_CODE`. `HANDOFF_CODE_EXPIRED` is returned only when the stored handoff is still present after its five minutes. +A token that resolves to no live session returns 401 `INVALID_TOKEN`. A malformed, unknown, completed or uninspected code returns 400 `INVALID_HANDOFF_CODE`, as does an exhausted failed-attempt allowance. An expired code returns `INVALID_HANDOFF_CODE` or `HANDOFF_CODE_EXPIRED`. Start a new handoff in either case. Session creation can also return 403 `BOT_USER_AUTH_SESSION_CREATION_DENIED` for a bot account, and 403 `REGISTRATION_PENDING_APPROVAL` or 403 `REGISTRATION_REJECTED` for an account that has not been admitted. A suspended account returns the suspension codes listed under [log in with a password](#log-in-with-a-password). @@ -1436,7 +1421,7 @@ Reports the state of a handoff and delivers the new session token once, to a cal | poll_secret | string | The secret returned by [initiate desktop handoff](#initiate-desktop-handoff) | :::caution[The completed status is delivered exactly once] -Reading a completed handoff deletes the stored token as it returns it. The very next poll with the same code reports `expired`, so the initiating device MUST retain the token when it is first received. +The token is returned once. Later polls report `expired`, so the initiating device MUST retain it when first received. ::: A secret that does not match reports `pending` and records a failed attempt for the polling IP address. The route reports no distinct error for a wrong secret, so a caller cannot tell a wrong secret from a handoff the approving device has not finished. @@ -1454,7 +1439,7 @@ A secret that does not match reports `pending` and records a failed attempt for ### Side effects -A poll that matches the secret deletes the stored token as it delivers it, so later polls report `expired`. Every other poll leaves the handoff unchanged. No account state changes and no Gateway Dispatch is emitted. +Retrieving the token completes the handoff. Other polls leave it unchanged. No Gateway Dispatch is emitted. ### Rate limit @@ -1490,9 +1475,8 @@ The route checks no credential and the code is its only input, so a party that l ### Side effects -The pending handoff, its inspected record, and any completed token that has not yet been read are all deleted. Cancelling does not revoke a session that a completion already created. When the initiating device never polled that completion, the cancellation leaves a live session no device holds a token for. No Gateway Dispatch is emitted. +Cancelling prevents any further token retrieval but does not revoke a session already created by completion. No Gateway Dispatch is emitted. ### Rate limit 10 requests per minute, on the `auth:handoff:cancel` bucket. - diff --git a/fluxer_docs/src/content/docs/http-api/billing.mdx b/fluxer_docs/src/content/docs/http-api/billing.mdx index dc529d554..9bfbc7cd1 100644 --- a/fluxer_docs/src/content/docs/http-api/billing.mdx +++ b/fluxer_docs/src/content/docs/http-api/billing.mdx @@ -260,11 +260,9 @@ A blocked purchase always fails on [create localised card preapproval](#create-l ### Side effects -The operation creates a payment provider customer when the account has none and records the customer identifier on the account. It also reconciles the account's stored subscription identifier with whatever the provider reports for that customer. Where a blocking subscription can still grant premium, Fluxer repairs the account's premium type, start, end, cancellation flag, billing cycle and grace deadline before it refuses the request. +Checkout collects the required consent, tax information and any promotion code on the provider's pages. -It then creates an externally hosted checkout with terms of service consent, the withdrawal waiver text attached to that consent, automatic tax, tax identifier collection and promotion codes. It records a pending payment row with the resolved countries and the waiver decision. - -A converted purchase creates no checkout session and no payment row. Fluxer builds or updates the subscription schedule, clears pending cancellation, and sends [User Update](/gateway/events/#user-update) to every account session. +A converted purchase schedules a billing cycle change instead of opening checkout. It clears pending cancellation and sends [User Update](/gateway/events/#user-update) to every account session. Premium entitlement otherwise changes only after the matching signed event reaches [receive Stripe webhook](#receive-stripe-webhook). @@ -311,9 +309,7 @@ A submitted `payment_method` is validated against the enum and then discarded. T ### Side effects -The operation creates a payment provider customer when the account has none and reconciles subscription state exactly as [create subscription checkout](#create-subscription-checkout) does, then creates an externally hosted setup session restricted to cards. - -It also records a preapproval flow keyed by a newly generated continuation token, holding the price, country, business flag, waiver decision and observed countries. The flow expires one day after its most recent state change. The token is returned in the session's success callback URL. +The returned URL opens a card-only setup flow. Its success callback includes the continuation token. The flow expires one day after its most recent state change. The flow stays pending until [receive Stripe webhook](#receive-stripe-webhook) processes the matching completion event. It is approved only when the card issuing country equals the pricing country. Otherwise the flow records a [preapproval rejection reason](#preapproval-rejection-reasons), and for `country_mismatch` the detected country. No entitlement changes. @@ -410,7 +406,7 @@ A submitted `pix` or `upi` is validated against the enum and then discarded. The ### Side effects -The operation creates a payment provider customer when necessary, creates an externally hosted one-off checkout with invoice creation enabled, and records a pending payment row for the session. No [gift](/http-api/gifts/#gift-object) exists yet. The gift is created only after [receive Stripe webhook](#receive-stripe-webhook) processes the matching paid event, after which it appears in [List current user gifts](/http-api/users/gifts/#list-current-user-gifts). +The returned URL opens a one-off checkout. The [gift](/http-api/gifts/#gift-object) appears in [List current user gifts](/http-api/users/gifts/#list-current-user-gifts) only after payment is confirmed. ### Rate limit @@ -443,8 +439,6 @@ The session requests 3-D Secure authentication on the card and takes no payment. ### Side effects -The operation creates a payment provider customer when necessary, records the customer identifier on the account, and creates an externally hosted setup session restricted to cards. - Adult age verification is granted only after [receive Stripe webhook](#receive-stripe-webhook) processes the matching completion event and confirms a credit card. The grant sets the age-verified adult flag and sends [User Update](/gateway/events/#user-update) to every session owned by the account. ### Rate limit @@ -459,10 +453,10 @@ Returns the [refund eligibility](#refund-eligibility-object) object for the auth A deployment with no configured payment provider answers with `eligible` false and the reason `feature_unavailable`. Otherwise the operation lists the five most recent invoices for the account's payment provider customer and selects the first that is paid, has a positive amount, and resolves a payment intent or a charge. -A provider listing failure is logged and treated as no refundable purchase, and the request still succeeds. An account that has never had a payment provider customer created is treated the same way. +A failed payment-history lookup or an account with no payment history reports no refundable purchase. :::note[The same object appears inside premium state] -[Get premium state](/http-api/premium/#get-premium-state) returns this object as `billing.refund_eligibility`, computed from the mirrored invoices, so the two can disagree while the mirror is behind. +[Get premium state](/http-api/premium/#get-premium-state) also returns `billing.refund_eligibility`, which can lag behind this endpoint. ::: ### Response @@ -518,7 +512,7 @@ The operation creates a refund for the invoice amount paid, reduced by one minor A refund the provider confirms as succeeded in the same call cancels the subscription that produced the invoice and starts the 30-day cooldown from that moment. A refund still reported as pending or as awaiting further action does neither. [Receive Stripe webhook](#receive-stripe-webhook) cancels the subscription and starts the cooldown when it processes the provider event that confirms the refund. -A confirmed refund that cancels the account's current subscription clears the stored subscription, billing cycle, cancellation flag and grace deadline, and every account session receives [User Update](/gateway/events/#user-update). A cancellation that fails is logged and left for the webhook to reconcile. +A confirmed refund that cancels the current subscription updates the account's premium state and sends [User Update](/gateway/events/#user-update) to every account session. ### Rate limit @@ -528,7 +522,7 @@ A confirmed refund that cancels the account's current subscription clears the st -Authenticates a payment provider event against the exact raw request body and queues it for asynchronous processing, returning a [webhook received](#webhook-received-object) object. The signature header is the credential. +Accepts a signed payment provider event for asynchronous processing and returns a [webhook received](#webhook-received-object) object. The signature header is the credential. ### Limitations @@ -536,8 +530,6 @@ Authenticates a payment provider event against the exact raw request body and qu - A deployment with no payment provider client or webhook secret configured then returns 400 `STRIPE_WEBHOOK_NOT_AVAILABLE`. - A signature header that does not verify returns 401 `STRIPE_WEBHOOK_SIGNATURE_INVALID`. -The route verifies the signature with the payment provider library's default timestamp tolerance. - :::caution[The signature covers the exact bytes] A proxy that re-encodes, reorders or pretty-prints the payload invalidates the signature. ::: @@ -561,11 +553,7 @@ A proxy that re-encodes, reorders or pretty-prints the payload invalidates the s The response comes before processing, and a redelivered event is applied once. -A recognised event first refreshes the local mirror of its provider object, covering customers, products, prices, checkout sessions, subscriptions, invoices, payment intents, payment methods, charges, refunds and disputes. [Get premium state](/http-api/premium/#get-premium-state) reads that mirror. - -Later processing can create a purchased gift, grant or extend premium, settle or fail a checkout, and update invoice failure state. It can also record or clear the post-cancellation grace deadline, apply subscription changes, resolve a card preapproval, complete a donation and send its confirmation email, and complete adult age verification. A dispute or fraud warning reverses a gift and emails the redeemer whose entitlement was withdrawn. A dispute that closes in Fluxer's favour restores the account and emails that account holder. A refund reverses entitlement and settles the self-service refund. - -Each account-addressed message is sent only when the receiving account has an email address. The resulting account changes reach connected sessions as [User Update](/gateway/events/#user-update). +Accepted events update payment, subscription, gift, donation and age-verification results. Resulting account changes reach connected sessions as [User Update](/gateway/events/#user-update). ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/calls.mdx b/fluxer_docs/src/content/docs/http-api/calls.mdx index 3480bd1d3..e84bfac77 100644 --- a/fluxer_docs/src/content/docs/http-api/calls.mdx +++ b/fluxer_docs/src/content/docs/http-api/calls.mdx @@ -14,11 +14,9 @@ Every route on this page is user-only. Bot and OAuth2 credentials are rejected. ## Access rules -The routes below share one boundary. [Get call eligibility](#get-call-eligibility), [Modify call region](#modify-call-region), [Ring call recipients](#ring-call-recipients), and [Stop ringing call recipients](#stop-ringing-call-recipients) each resolve the channel named by the path parameter first, then apply the same checks in order. +[Get call eligibility](#get-call-eligibility), [Modify call region](#modify-call-region), [Ring call recipients](#ring-call-recipients), and [Stop ringing call recipients](#stop-ringing-call-recipients) require a direct message or group direct message whose recipients include the caller. -Fluxer answers 404 `UNKNOWN_CHANNEL` for a channel ID that names no channel. A channel that exists but is neither a direct message nor a group direct message returns 400 `INVALID_CHANNEL_TYPE_FOR_CALL`. That type check runs before the membership check, so any authenticated account can learn that an arbitrary channel exists whenever that channel is not a private channel. - -A private channel whose recipient set does not contain the caller returns 404 `UNKNOWN_CHANNEL`. An absent channel returns the same code, so a caller cannot tell the two apart. +An absent private channel or a caller who is not a recipient returns 404 `UNKNOWN_CHANNEL`. An existing channel of another type returns 400 `INVALID_CHANNEL_TYPE_FOR_CALL`. [End call session](#end-call-session) applies none of these checks. @@ -26,7 +24,7 @@ A private channel whose recipient set does not contain the caller returns 404 `U A private call has no moderator and no permission overwrites. No account other than the participant itself can change that participant's voice state, and the owner of a group direct message is no exception. The `mute`, `deaf`, and `suppress` fields of a [voice state](/gateway/events/#voice-state-object) that belongs to a call are therefore always false. Only the participant's own [Voice State Update](/gateway/commands/#voice-state-update) changes `self_mute`, `self_deaf`, `self_video`, or `self_stream`. -[Modify guild member](/http-api/guild-members/#modify-guild-member) is the only operation that applies a moderator mute, applies a moderator deafen, or forces a disconnect. It is addressed by guild ID, so it can never reach a call. No route on this page and no Gateway command disconnects another participant from a call. A participant leaves a call at its own request, when its Gateway session ends, or when Fluxer reconciles against the media server and finds its media connection gone. +[Modify guild member](/http-api/guild-members/#modify-guild-member) cannot moderate private calls. A participant leaves at their own request or when their connection ends. No route on this page or Gateway command can disconnect another participant. Silencing another participant happens in the client. A client MAY mute a participant or change per-participant volume. The client MUST keep each setting local to the listening device, so neither reaches the Gateway or any other participant. @@ -199,17 +197,17 @@ A named recipient is only rung when the incoming call policy described by [Get c ### Side effects -Fluxer first reopens the private channel for the caller and for every notified recipient, and each account for which the channel was closed receives [Channel Create](/gateway/events/#channel-create). +The private channel reopens for the caller and notified recipients. An account whose channel was closed receives [Channel Create](/gateway/events/#channel-create). -When no call exists, Fluxer then stores a call system message with the initial participant set and creates the call with [Call Create](/gateway/events/#call-create). The event has the initial ringing set and the automatically selected region. Fluxer raises the unread mention count of every other recipient that is not a bot and has not blocked the caller. It acknowledges the message for the initiating user without a Dispatch, and only then delivers the stored message with [Message Create](/gateway/events/#message-create). Fluxer sends [Call Create](/gateway/events/#call-create) before [Message Create](/gateway/events/#message-create). +A new call emits [Call Create](/gateway/events/#call-create) with its ringing set and automatic region, followed by a call system message through [Message Create](/gateway/events/#message-create). The message is read for the caller and increments the unread mention count for other non-bot recipients who have not blocked the caller. When a call already exists, Fluxer instead extends the ringing set and emits [Call Update](/gateway/events/#call-update) when the set grows. A recipient who is already connected to the call is never added to the ringing set. -Each rung recipient holds a ringing entry for 30 seconds. Fluxer then drops the entry and emits [Call Update](/gateway/events/#call-update) again. When that expiry leaves the call with no connected participant and no other ringing recipient, Fluxer removes the call in the same step. That emits [Call Delete](/gateway/events/#call-delete) and stamps the call system message with its ended timestamp. A call that rang at least one recipient and that nobody joined therefore ends 30 seconds after the ring. +Ringing lasts 30 seconds. Its expiry emits [Call Update](/gateway/events/#call-update). If nobody has joined and no recipient remains ringing, the call ends with [Call Delete](/gateway/events/#call-delete). A call created with an empty ringing set has no ring timer, so it ends on the 120 second idle timer instead. An explicit empty `recipients` array produces such a call, and so does a ring that admits no candidate audibly. -Whenever Fluxer removes a call, it rewrites that call's system message with the ended timestamp and with every account that ever connected. It publishes the rewritten message to every recipient of the private channel as [Message Update](/gateway/events/#message-update). +When a call ends, every private-channel recipient receives [Message Update](/gateway/events/#message-update) with the call's end time and participants. The call's recipient list is fixed when the call is created. Changing the recipient set of a group direct message afterwards neither ends the call nor updates that list. [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient), [Remove group direct message recipient](/http-api/channels/#remove-group-direct-message-recipient), and [Delete or leave channel](/http-api/channels/#delete-or-leave-channel) leave a running call in place. A recipient added later receives no Dispatch for it. @@ -258,9 +256,7 @@ The body can be omitted. Fluxer treats a missing, empty, or whitespace-only body ### Side effects -Each named recipient loses its ringing entry and its 30 second ring timer. When the ringing set changes, Fluxer emits [Call Update](/gateway/events/#call-update) to every recipient the call was created with. This operation never removes the call itself. - -The 120 second idle timer removes a call left with no connected participant and no ringing recipient, which emits [Call Delete](/gateway/events/#call-delete). This operation does not restart that timer. The timer starts when the call is created and restarts on each join and on each expiry that still finds a connected participant or a ringing recipient, so removal can follow within a fraction of that window. +When the ringing set changes, every call recipient receives [Call Update](/gateway/events/#call-update). This operation does not end the call immediately. A call with no participants or ringing recipients ends within the existing 120 second idle window and emits [Call Delete](/gateway/events/#call-delete). ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/channels.mdx b/fluxer_docs/src/content/docs/http-api/channels.mdx index ef7f9faee..1f638d928 100644 --- a/fluxer_docs/src/content/docs/http-api/channels.mdx +++ b/fluxer_docs/src/content/docs/http-api/channels.mdx @@ -239,10 +239,6 @@ Fluxer enforces age verification only for a guild text, voice, or link channel. | 403 | [error response](/http-api/#error-response) | The resolved age restriction is not satisfied and the request returns `NSFW_CONTENT_AGE_RESTRICTED` | | 404 | [error response](/http-api/#error-response) | Channel does not exist, or the caller is not a recipient of the private channel, each returning `UNKNOWN_CHANNEL`, or the guild that owns the channel does not exist and the request returns `UNKNOWN_GUILD` | -:::note[The guild resolves after the channel] -A channel whose guild is gone fails with `UNKNOWN_GUILD`, not `UNKNOWN_CHANNEL`. -::: - ### Side effects Requesting the personal notes channel of the authenticated user creates it when it does not already exist, and returns it with no [Channel Create](/gateway/events/#channel-create) Dispatch. @@ -292,7 +288,7 @@ Returns the [RTC region objects](#rtc-region-object) the authenticated user can - The caller needs the same access as [Get channel](#get-channel). - The channel must be a guild voice channel. -Fluxer returns a region only when the caller passes every restriction configured for it and at least one active voice server in that region is also accessible to the caller. The array can be empty. [Placement eligibility](/admin-api/voice/#placement-eligibility) states the stored fields those restrictions come from. +Only regions available to the caller are returned. The array can be empty. :::note[An unconfigured deployment skips the channel check] On a deployment that configures no voice topology, a missing channel, an invisible channel, and a non-voice channel all return 200 with `[]`. @@ -546,7 +542,7 @@ An account that stores neither a password nor a second factor is verified withou Deleting a guild category first clears the `parent_id` of every child channel and emits [Channel Update](/gateway/events/#channel-update) for each one. The category itself is then deleted like any other guild channel. -Deleting a guild channel deletes its invites and webhooks, purges the stored attachments of its messages, deletes its messages, and removes those messages from search. Fluxer then emits [Channel Delete](/gateway/events/#channel-delete) to every session subscribed to the guild, records a channel delete audit log entry with the previous channel state and no reason, and removes the channel record. A guild that named the channel as its system, rules, or AFK channel has that reference cleared and emits [Guild Update](/gateway/events/#guild-update). +Deleting a guild channel permanently removes its messages, attachments, invites and webhooks. Guild subscribers receive [Channel Delete](/gateway/events/#channel-delete), and the deletion appears in the audit log. A system, rules or AFK channel reference is cleared with [Guild Update](/gateway/events/#guild-update). Closing a direct message marks the channel closed for the caller alone and emits [Channel Delete](/gateway/events/#channel-delete) to that caller. The channel, its messages, and the other recipient's view are untouched. @@ -614,7 +610,7 @@ The `MAX_GROUP_DM_RECIPIENTS` body has `max_recipients`, the exact ceiling that ### Side effects -A successful addition stores the new recipient set and opens the channel for the added user. The added user receives [Channel Create](/gateway/events/#channel-create), existing recipients receive [Channel Recipient Add](/gateway/events/#channel-recipient-add), and every recipient receives the addition system message through [Message Create](/gateway/events/#message-create). Adding a user who is already a recipient changes nothing, consumes no capacity, and emits no Dispatch. +The added user receives [Channel Create](/gateway/events/#channel-create), existing recipients receive [Channel Recipient Add](/gateway/events/#channel-recipient-add), and every recipient receives an addition system message through [Message Create](/gateway/events/#message-create). Adding an existing recipient changes nothing and emits no Dispatch. ### Rate limit @@ -674,7 +670,7 @@ The optional body has the same [sudo verification fields](#sudo-verification-fie ### Side effects -The removal has the same state and Dispatch effects as leaving through [Delete or leave channel](#delete-or-leave-channel). It stores the reduced recipient set, removes the recipient's nickname, and closes the channel for the recipient. When the removed recipient owned the group, ownership transfers to one of the remaining recipients chosen at random. +Removal closes the channel for the recipient and clears their nickname. If they owned the group, ownership transfers to a randomly selected remaining recipient, as for [Delete or leave channel](#delete-or-leave-channel). The removed recipient receives [Channel Delete](/gateway/events/#channel-delete). Remaining recipients receive [Channel Recipient Remove](/gateway/events/#channel-recipient-remove), and the removal system message through [Message Create](/gateway/events/#message-create) unless `silent` is true. No [Channel Update](/gateway/events/#channel-update) reports the ownership transfer or the removed nickname. diff --git a/fluxer_docs/src/content/docs/http-api/connections.mdx b/fluxer_docs/src/content/docs/http-api/connections.mdx index fe33992fa..9a0aeaae5 100644 --- a/fluxer_docs/src/content/docs/http-api/connections.mdx +++ b/fluxer_docs/src/content/docs/http-api/connections.mdx @@ -6,15 +6,15 @@ description: External account connections, domain ownership proof, Bluesky autho import RouteHeader from '@/components/RouteHeader.astro'; -A connection links a Fluxer account to an outside identity the account has proved it controls. The [connection types](#connection-types) are a DNS domain and a Bluesky account authorised through atproto OAuth. A profile renders an account's connections as the `connected_accounts` field of the [full user profile object](/http-api/users/#full-user-profile-object). +A connection links a Fluxer account to a verified domain or Bluesky account. Visible connections appear in `connected_accounts` on the [full user profile object](/http-api/users/#full-user-profile-object). -Every route that reads or writes a connection is user-only. A bot token is rejected with 403 `ACCESS_DENIED`, and so is an OAuth2 bearer except on [List connections](#list-connections), which accepts a bearer with the `connections` [scope](/http-api/oauth2/#oauth2-scopes). An account with an outstanding required action is rejected with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. No connection identifier is a snowflake. +Connection routes require a user token. Bot tokens and OAuth2 bearers receive 403 `ACCESS_DENIED`, except that [List connections](#list-connections) accepts a bearer with the `connections` [scope](/http-api/oauth2/#oauth2-scopes). Accounts with an outstanding required action receive 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. Connection IDs are not snowflakes. -[Get Bluesky client metadata](#get-bluesky-client-metadata) and [Get Bluesky JWKS](#get-bluesky-jwks) are the exception. They read no credential, publish no account data, and serve the atproto authorisation servers the [Bluesky authorisation flow](#start-bluesky-authorisation) talks to. +[Bluesky client metadata](#get-bluesky-client-metadata) and [JWKS](#get-bluesky-jwks) are public and contain no account data. ## Account connection limit -An account holds at most 20 connections in total across every [connection type](#connection-types). Fluxer reports the ceiling as 400 `CONNECTION_LIMIT_REACHED` with an [error response](/http-api/#error-response) body. +An account can have at most 20 connections across all types. Exceeding this limit returns 400 `CONNECTION_LIMIT_REACHED`. [Initiate connection](#initiate-connection), [Verify and create connection](#verify-and-create-connection), and the provider callback that completes the [Bluesky authorisation flow](#start-bluesky-authorisation) each enforce the ceiling. [Start Bluesky authorisation](#start-bluesky-authorisation) does not. @@ -33,17 +33,15 @@ The pair of [type](#connection-types) and `id` identifies a connection. Both val | visibility_flags4 | integer | [Connection visibility flags](#connection-visibility-flags) | | sort_order5 | integer | The display order within the account's connection list (0-2147483647) | -1 A `domain` connection identifier is 64 lowercase hexadecimal characters, the SHA-256 of the owning account identifier and the lowercased domain joined by one ASCII colon, so deleting and recreating the same domain reproduces the same identifier. A `bsky` connection identifier is a random UUID assigned when the connection is first created +1 A `domain` identifier is 64 lowercase hexadecimal characters and stays the same when the account recreates that domain connection. A `bsky` identifier is a UUID assigned at creation -2 For a `domain` connection the name is the domain exactly as it was submitted. For a `bsky` connection it is the Bluesky handle resolved at the most recent completion of the [Bluesky authorisation flow](#start-bluesky-authorisation), so it changes when the account is renamed upstream and is not refreshed by [Verify connection](#verify-connection) +2 For a `domain` connection the name is the submitted domain. For a `bsky` connection it is the Bluesky handle, refreshed by completing the [authorisation flow](#start-bluesky-authorisation) -3 A connection is created only after a proof succeeds, so the value starts true, and only a failed [Verify connection](#verify-connection) against an already verified connection sets it to false +3 Starts true. A failed ownership recheck sets it to false 4 The stored value is the integer the client supplied and is not masked against the defined bits -5 A newly created connection receives the number of connections the account already held, and [Update connection](#update-connection) and [Reorder connections](#reorder-connections) rewrite it - -A `bsky` connection is identified by the account's decentralised identifier while `name` is the current handle, so the connection survives an upstream rename. +5 Initially the number of existing connections. Use [Update connection](#update-connection) or [Reorder connections](#reorder-connections) to change it ## Connection types @@ -52,7 +50,7 @@ A `bsky` connection is identified by the account's decentralised identifier whil | bsky1 | BLUESKY | A Bluesky account authorised through atproto OAuth | | domain | DOMAIN | A domain the account has proved it controls | -1 [Initiate connection](#initiate-connection) rejects this type with 400 `BLUESKY_OAUTH_NOT_ENABLED` before it checks the connection ceiling. A Bluesky connection is created only by completing the flow that begins at [Start Bluesky authorisation](#start-bluesky-authorisation) +1 Use [Start Bluesky authorisation](#start-bluesky-authorisation). The domain initiation route rejects this type with 400 `BLUESKY_OAUTH_NOT_ENABLED` ## Connection visibility flags @@ -88,27 +86,23 @@ Returned by [Initiate connection](#initiate-connection). No connection exists un 3 The instructions name both the DNS TXT record and the well-known path described under [Domain ownership proof](#domain-ownership-proof) -4 The value is a base64url JSON payload and its base64url HMAC-SHA256 signature separated by one ASCII full stop. It binds the account identifier, the type, the identifier, and the verification token, and has an expiry 30 minutes after issue +4 An opaque credential that expires 30 minutes after issue and can be used only by the initiating account -:::caution[Anyone holding the initiation token can read it] -The payload is ordinary base64url and has the verification token in the clear. Treat the initiation token as a credential and do not log or forward it. +:::caution[Protect the initiation token] +Do not log or forward it. Publish only the verification token used by the ownership proof. ::: ## Domain ownership proof -Fluxer attempts a `domain` proof against the stored identifier exactly as it was submitted. [Initiate connection](#initiate-connection) accepts any string of 1 to 253 characters and applies no syntax check of its own, so a malformed value is accepted and then fails its proof. +Submit the domain exactly as it should be verified. A malformed domain can pass initiation but fail ownership verification. -Fluxer first resolves the TXT records of `_fluxer.`. It issues the query in parallel to a fixed set of public DNS resolvers, each with a 2000 millisecond timeout and one attempt. The proof succeeds when any resolver returns a record whose concatenated strings equal `fluxer-verification=` exactly. A resolver that fails or times out is treated like an absent record and produces no separate error. +Publish a TXT record at `_fluxer.` whose value is exactly `fluxer-verification=`. If the record has multiple strings, their concatenation must match that value. Allow time for DNS changes to propagate before verifying. -When the DNS proof fails, Fluxer issues one HTTPS `GET` to `https:///.well-known/fluxer-verification` with a 5000 millisecond timeout. It follows up to 5 redirects. A sixth redirect fails the proof, as does a redirect response that has no `Location` header. The proof succeeds only when the status of the final response is in the 2xx range and the whitespace-trimmed body equals the token exactly. A response body over the 16384 byte ceiling fails the proof, and a declared `Content-Length` over that ceiling fails it before the body is read. +Alternatively, serve the token at `https:///.well-known/fluxer-verification`. The response must have a 2xx status and a body that matches the token after trimming whitespace. The body must not exceed 16384 bytes. Up to 5 HTTP or HTTPS redirects are accepted. -The instance outbound URL policy applies to the initial request and to every redirect it follows. A redirect target must use the `http` or `https` scheme. Fluxer lowercases the host and strips one trailing dot before the check. +The HTTPS URL and every redirect must use a valid fully qualified domain name or public IP address. All resolved addresses must be publicly reachable and permitted by the instance's outbound URL policy. Private and reserved networks cannot be verified this way. -A host that is an IP literal cannot sit in a loopback, private, shared, link-local, documentation, benchmarking, multicast, or otherwise reserved range. An IPv6 literal that embeds an IPv4 address is checked against the IPv4 ranges. A `domain` identifier that is a public IP literal skips the name checks below and can complete the HTTPS proof. - -Any other host must be a fully qualified domain name of at most 253 characters with at least one dot. Every label must be 1 to 63 characters of ASCII letters, digits, and hyphens that neither begins nor ends with a hyphen. The final label cannot consist only of digits, and every address the host resolves to must be a public internet address. A host that fails any of those checks fails the proof, so a domain pointed at a loopback, link-local, or private range is never provable this way. - -[Verify and create connection](#verify-and-create-connection) takes the token from the signed initiation token. [Verify connection](#verify-connection) reuses the connection's stored verification token, so a recheck expects the same DNS record or document as the original proof. +Fluxer checks the proof once, when the connection is created. There is no later recheck. :::note[Only the DNS record has the prefix] The DNS record value has the `fluxer-verification=` prefix and the well-known document does not. A document that contains the prefixed form fails the HTTPS proof. @@ -122,9 +116,7 @@ Returned by [Start Bluesky authorisation](#start-bluesky-authorisation). | Field | Type | Description | | --- | --- | --- | -| authorize_url1 | string | The atproto authorisation server URL the user is sent to | - -1 The URL is issued by the handle's own authorisation server, so its origin varies with the account being connected +| authorize_url | string | URL to open for authorisation. Its origin depends on the account | ## List connections @@ -132,7 +124,7 @@ Returned by [Start Bluesky authorisation](#start-bluesky-authorisation). Returns every [connection object](#connection-object) the authenticated account holds, in ascending `sort_order`.1 An account with no connections returns an empty array. -Fluxer checks the scope before it checks the account state, so a bearer without `connections` is rejected with 403 `MISSING_OAUTH_SCOPE` even when the account also has an outstanding required action. +A bearer without `connections` receives 403 `MISSING_OAUTH_SCOPE`. 1 The sort is stable and has no secondary key, so two connections sharing a `sort_order` are returned in stored order, ascending by [connection type](#connection-types) and then descending by `id` @@ -159,8 +151,6 @@ Begins domain ownership verification and returns a [connection verification obje Only the `domain` type is accepted. A Bluesky connection is created through [Start Bluesky authorisation](#start-bluesky-authorisation). -Fluxer checks the requested type first, then the 20-connection ceiling, and then the duplicate identifier. An account at the ceiling receives the ceiling failure even when the submitted identifier would also have been a duplicate. - ### JSON body | Field | Type | Description | @@ -169,7 +159,7 @@ Fluxer checks the requested type first, then the 20-connection ceiling, and then | identifier | string | The domain to prove ownership of (1-253 characters) | | visibility_flags?1 | integer | [Connection visibility flags](#connection-visibility-flags) (0-2147483647) | -1 Accepted by the schema and then ignored, because [Verify and create connection](#verify-and-create-connection) chooses the visibility when it creates the connection +1 Ignored here. Set visibility in [Verify and create connection](#verify-and-create-connection) ### Response @@ -181,7 +171,7 @@ Fluxer checks the requested type first, then the 20-connection ceiling, and then ### Side effects -The operation creates no connection and emits no Gateway Dispatch. It returns a signed initiation token that expires 30 minutes after issue and a verification token the caller MUST publish at the domain before calling [Verify and create connection](#verify-and-create-connection). +No Gateway event is emitted. Publish the verification token at the domain, then call [Verify and create connection](#verify-and-create-connection) before the initiation token expires. ### Rate limit @@ -193,7 +183,7 @@ The operation creates no connection and emits no Gateway Dispatch. It returns a Checks the [domain ownership proof](#domain-ownership-proof) described by a signed initiation token. Creates and returns the [connection object](#connection-object) on success. -The type, identifier, and verification token all come from the signed token, so a caller cannot verify a target it did not initiate. Fluxer re-evaluates the 20-connection ceiling and the duplicate identifier before it attempts the proof. A target that became a duplicate since the token was issued is refused with no DNS or HTTPS lookup. +Use the initiation token returned for this domain and account. The connection limit and duplicate checks still apply when verification completes. ### JSON body @@ -211,13 +201,13 @@ The type, identifier, and verification token all come from the signed token, so | 201 | [connection](#connection-object) object | Proof succeeded and the connection was created | | 4001 | [error response](/http-api/#error-response) | The initiation token is not usable and the request returns `CONNECTION_INITIATION_TOKEN_INVALID` | | 403 | [error response](/http-api/#error-response) | The proof failed and the request returns `CONNECTION_VERIFICATION_FAILED` | -| 409 | [error response](/http-api/#error-response) | A connection of the same type already exists for that identifier, compared case-insensitively, and the request returns `CONNECTION_ALREADY_EXISTS` | +| 409 | [error response](/http-api/#error-response) | The identifier is already linked (`CONNECTION_ALREADY_EXISTS`), or the connection list changed during the request (`CONFLICT`). Fetch the list before retrying | -1 A malformed token, a token whose HMAC does not verify, an expired token, and a token issued to another account all produce the same code, so a caller cannot distinguish them +1 Invalid, expired or wrong-account initiation tokens return the same code ### Side effects -A successful proof creates one connection with `verified` set to true, `sort_order` set to the number of connections the account already held, and `visibility_flags` set to the supplied value or EVERYONE. The complete connection list is then published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. A failed proof creates no connection and emits no Dispatch. +Success creates the connection and sends the complete list to the caller's sessions in [User Connections Update](/gateway/events/#user-connections-update). A failed proof creates no connection and emits no event. The initiation token stays usable until it expires, so a caller that publishes its proof late can retry with the same token. @@ -251,12 +241,13 @@ Updates the visibility or display order of one existing connection and returns 2 | --- | --- | --- | | 204 | empty | Connection was updated | | 4041 | [error response](/http-api/#error-response) | No connection of that type and identifier is owned by the caller and the request returns `CONNECTION_NOT_FOUND` | +| 409 | [error response](/http-api/#error-response) | The connection changed during the request (`CONFLICT`). Fetch it again before retrying | 1 Both path parameters must name the same connection, so a correct identifier under the wrong type does not match ### Side effects -Each supplied field replaces the stored value. The complete connection list is then published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. The Dispatch is emitted even when every supplied value already matched, and even when the body supplies no field at all. +Sends the complete list to the caller's sessions in [User Connections Update](/gateway/events/#user-connections-update), even if no values changed or the body was empty. Assigning a `sort_order` that another connection already holds is permitted. [List connections](#list-connections) then resolves the tie by stored order. @@ -283,63 +274,22 @@ Permanently removes one connection and returns 204 with an empty body. | --- | --- | --- | | 204 | empty | Connection was deleted | | 404 | [error response](/http-api/#error-response) | No connection of that type and identifier is owned by the caller and the request returns `CONNECTION_NOT_FOUND` | +| 409 | [error response](/http-api/#error-response) | The connection or connection list changed during the request (`CONFLICT`). Fetch the list before retrying | ### Side effects -The connection is deleted and the remaining connection list is published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. Deletion does not renumber the `sort_order` of the remaining connections, so the surviving values can have a gap. +Sends the remaining list to the caller's sessions in [User Connections Update](/gateway/events/#user-connections-update). Other connections keep their `sort_order` values. -Deleting a `bsky` connection does not revoke its stored atproto authorisation, which is retained for 24 hours after it was last written and then expires on its own. +Deleting a `bsky` connection removes it from Fluxer but does not revoke authorisation on Bluesky. :::caution[Deletion is immediate] -There is no grace period and no restore operation. Recreating a `domain` connection requires a fresh [Initiate connection](#initiate-connection) and a republished proof, and recreating a `bsky` connection requires completing the OAuth flow again. +There is no grace period or restore operation. To recreate a `domain` connection, [initiate a new connection](#initiate-connection) and verify the ownership proof. To recreate a `bsky` connection, complete the OAuth flow again. ::: ### Rate limit 10 requests per minute for each authenticated user, on the `connection:delete` bucket. -## Verify connection - - - -Rechecks the external proof of an existing connection and returns the resulting [connection object](#connection-object). - -A `domain` connection is rechecked against the [domain ownership proof](#domain-ownership-proof) using its stored identifier and stored verification token. A `bsky` connection is rechecked by restoring the stored atproto session for its decentralised identifier and resolving that account's current public handle. - -A `bsky` recheck succeeds only while the stored atproto session survives. That session is retained for 24 hours after it was last written, so a connection whose session has expired fails the recheck until the user completes [Start Bluesky authorisation](#start-bluesky-authorisation) again. - -:::note[A recheck never renames a connection] -The handle a `bsky` recheck resolves proves the session is live and is then discarded. Only completing the [Bluesky authorisation flow](#start-bluesky-authorisation) writes `name`. -::: - -### Path parameters - -| Field | Type | Description | -| --- | --- | --- | -| type | string | [Connection type](#connection-types) | -| connection_id | string | The ID of the connection, unique within its [connection type](#connection-types) | - -### Response - -| Status | Body | Condition | -| --- | --- | --- | -| 200 | [connection](#connection-object) object | Proof succeeded | -| 4001 | [error response](/http-api/#error-response) | A bsky connection is rechecked on an instance with no Bluesky OAuth client and the request returns `BLUESKY_OAUTH_NOT_ENABLED` | -| 403 | [error response](/http-api/#error-response) | The proof failed and the request returns `CONNECTION_VERIFICATION_FAILED` | -| 404 | [error response](/http-api/#error-response) | No connection of that type and identifier is owned by the caller and the request returns `CONNECTION_NOT_FOUND` | - -1 Every other recheck failure is reported as a failed proof. The route reads the configured OAuth client alone and never checks the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled` - -### Side effects - -A successful proof sets `verified` to true, refreshes the last-verified timestamp, and sets the original verification timestamp to now when the connection had none. It then publishes the complete connection list as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions. - -A failed proof against a connection that was verified sets `verified` to false, clears the original verification timestamp, and refreshes the last-verified timestamp. A failed proof against an already unverified connection writes nothing. Both publish the complete connection list as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch, so every session of the account observes the current `verified` value. - -### Rate limit - -5 requests per minute for each authenticated user, on the `connection:verify` bucket. - ## Reorder connections @@ -352,21 +302,22 @@ Assigns the display order of the listed connections from their position in the a | --- | --- | --- | | connection_ids1 2 | array[string] | The connection IDs in their new display order (1-20 entries) | -1 An entry that names no connection owned by the caller is skipped without an error, and a connection that is not named keeps its current order, so a partial array reorders only the connections it names +1 Unknown IDs are ignored. Omitted connections keep their current order -2 An entry is matched by `id` alone, and a repeated entry is applied once for each occurrence, so the last occurrence of a repeated identifier decides its final order +2 Connections are matched by `id` alone. For repeated IDs, the last position wins ### Response | Status | Body | Condition | | --- | --- | --- | | 204 | empty | Connections were reordered | +| 409 | [error response](/http-api/#error-response) | A connection changed during the request (`CONFLICT`). Fetch the list again before retrying | ### Side effects -Entries are applied in array order and each named connection receives its array index as the new `sort_order`. The complete connection list is then published as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to the caller's own sessions, even when the order was already identical. +Each named connection receives its zero-based array index as `sort_order`. The complete list is sent to the caller's sessions in [User Connections Update](/gateway/events/#user-connections-update), even if the order did not change. -Fluxer writes every `sort_order` in one atomic batch. A write that fails returns 500, applies no entry, and publishes no Dispatch. +The reorder is atomic. A rejected write changes no order and emits no event. A later notification failure can return an error after the order has been saved. A partial array can leave two connections sharing a `sort_order`, which [List connections](#list-connections) resolves by stored order. @@ -378,156 +329,70 @@ A partial array can leave two connections sharing a `sort_order`, which [List co -Normalises a Bluesky handle, begins the atproto OAuth authorisation flow, and returns a [Bluesky authorisation object](#bluesky-authorisation-object) whose URL the user is sent to. This operation creates no connection. - -Fluxer trims surrounding whitespace, then removes a leading `https://bsky.app/profile/` or `http://bsky.app/profile/` prefix matched case-insensitively, then removes one leading `@`. Everything that remains becomes the handle verbatim, so a profile URL with a further path segment submits that segment as part of the handle and fails upstream resolution. +Starts or renews a Bluesky connection. Bluesky must be enabled on the instance. Use this flow again when authorisation expires or the connection needs reauthorising. ### JSON body | Field | Type | Description | | --- | --- | --- | -| handle | string | The Bluesky handle or profile URL to connect (1-253 characters) | +| handle | string | Bluesky handle or profile URL (1-253 characters) | ### Response | Status | Body | Condition | | --- | --- | --- | -| 2001 | [Bluesky authorisation](#bluesky-authorisation-object) object | Authorisation was started | -| 4002 | [error response](/http-api/#error-response) | Bluesky connections are unavailable on this instance and the request returns `BLUESKY_OAUTH_NOT_ENABLED`, or the flow could not be started and the request returns `BLUESKY_OAUTH_AUTHORIZATION_FAILED` | -| 4093 | [error response](/http-api/#error-response) | A bsky connection whose name equals the normalised handle already exists and the request returns `CONNECTION_ALREADY_EXISTS` | - -1 This operation does not check the 20-connection ceiling, which is enforced only when the provider callback creates the connection - -2 Every authorisation failure other than an unavailable integration collapses into one code, including an unresolvable handle, a handle whose authorisation server is unreachable, and a rejected client registration, so a client MUST NOT use it to choose a fix - -3 The comparison is case-insensitive and runs against the stored connection `name`, so an account whose upstream handle has since changed is not detected as a duplicate here and refreshes its existing connection at the callback - -:::note[Independent availability gates] -This operation requires both a configured Bluesky OAuth client and the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled`. A missing client and a cleared flag both produce `BLUESKY_OAUTH_NOT_ENABLED`, and the response does not say which. -::: - -### Side effects - -The authorisation state is stored for one hour and identifies the account when the unauthenticated provider callback arrives. No connection is created and no Gateway Dispatch is emitted. - -:::caution[The authorisation state is single-use] -The provider callback reads and deletes the state in one operation. A replay and a callback more than one hour after issue both redirect with `callback_failed`. -::: +| 200 | [Bluesky authorisation](#bluesky-authorisation-object) object | Send the user to `authorize_url` | +| 400 | [error response](/http-api/#error-response) | Unavailable (`BLUESKY_OAUTH_NOT_ENABLED`) or failed (`BLUESKY_OAUTH_AUTHORIZATION_FAILED`) | ### Completing the flow -A client sends the user to the returned `authorize_url`, which is an atproto authorisation server URL. The user approves the request, and the authorisation server calls the instance's registered redirect URI, `/connections/bluesky/callback` on the `api_public` [instance endpoint](/http-api/instance/#instance-endpoints-object). A client MUST NOT call that route directly. +Send the user to `authorize_url`. Bluesky returns them through `/connections/bluesky/callback`. Clients must not call that route directly. -Fluxer consumes the state and exchanges the authorisation code for an atproto session. It resolves the account's decentralised identifier and current handle, writes the connection, and then redirects the browser to `/connection-callback` on the `webapp` [instance endpoint](/http-api/instance/#instance-endpoints-object). - -That redirect has `status=connected` on success. On failure it has `status=error` together with one `reason` value. +The browser then reaches `/connection-callback` on the web app with `status=connected`, or `status=error` and one of these reasons: | Value | Description | | --- | --- | -| not_enabled | The instance has no configured Bluesky OAuth client | -| state_invalid1 | The failure message names a state or an expiry | -| callback_failed2 | Any other failure of the callback itself | -| unknown3 | Any failure after the callback itself succeeded | +| not_enabled | Bluesky connections are unavailable | +| state_invalid | Invalid authorisation state. Start a new flow | +| callback_failed | Authorisation could not be completed. Start a new flow | +| unknown | The connection could not be saved | -1 A callback arriving with no `state` query parameter reports `state_invalid`, and a callback whose stored state is gone does not - -2 A replayed callback and a callback more than one hour after issue both land here - -3 An account already holding 20 connections lands here, as does any unexpected internal failure, so a client MUST NOT use this value to choose a fix - -On success, an account with no `bsky` connection for that decentralised identifier receives a new one. The new connection has `verified` set to true, EVERYONE visibility, and the number of connections the account already held as its order. An account that already has one keeps its identifier and `sort_order`, has its `name` rewritten to the current handle, and has `verified` set back to true. Either path publishes the complete connection list as a [User Connections Update](/gateway/events/#user-connections-update) Gateway Dispatch to that account's own sessions. - -Completing the flow is the only way a `bsky` connection `name` is written, so it is the only way to reconcile an upstream handle rename. It renews the 24-hour atproto session that [Verify connection](#verify-connection) rechecks. - -The callback does not check the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled`. An authorisation started while the flag was set completes normally after an operator clears it, as long as the OAuth client is still configured. +Success creates or refreshes the verified connection and publishes [User Connections Update](/gateway/events/#user-connections-update). Completing the flow again also refreshes the displayed Bluesky handle. ### Rate limit -5 requests per minute for each authenticated user, on the `connection:create` bucket, which is shared with [Initiate connection](#initiate-connection). +5 requests per minute for each authenticated user, on the `connection:create` bucket, shared with [Initiate connection](#initiate-connection). ## Get Bluesky client metadata -Returns the atproto OAuth client metadata document that registers this instance with every atproto authorisation server. - -The URL of this document is the `client_id` of the client. The authorisation server named by a handle fetches it while [Start Bluesky authorisation](#start-bluesky-authorisation) resolves that handle, and reads the redirect URI, the scope, and the client authentication method out of it. An instance that does not serve it to the public internet fails every authorisation with `BLUESKY_OAUTH_AUTHORIZATION_FAILED`. An instance that does not serve [Get Bluesky JWKS](#get-bluesky-jwks) authorises normally and then redirects with `callback_failed`, because the key set is read later, at the code exchange. - -Both documents are served whenever the instance has a configured Bluesky OAuth client. Clearing the instance [service availability](/http-api/instance/#service-availability-object) flag `bluesky_enabled` stops [Start Bluesky authorisation](#start-bluesky-authorisation) and leaves both documents in place. - -### Response body - -The document is the same for every request. Its URLs are built from the `api_public` [instance endpoint](/http-api/instance/#instance-endpoints-object), its display members come from instance configuration, and the protocol members are fixed. - -| Field | Type | Description | -| --- | --- | --- | -| client_id | string | The URL this document is served from, which is also the atproto identifier of the client | -| client_name | string | The name an authorisation server displays to the account holder | -| client_uri | string | The `api_public` [instance endpoint](/http-api/instance/#instance-endpoints-object) with any trailing slash removed | -| logo_uri?1 | string | The logo an authorisation server displays to the account holder | -| tos_uri?1 | string | The terms of service document offered to the account holder | -| policy_uri?1 | string | The privacy policy document offered to the account holder | -| redirect_uris | array[string] | The one URL an authorisation server may return to, `/connections/bluesky/callback` on `api_public` | -| grant_types | array[string] | The grants the client uses, `authorization_code` and `refresh_token` | -| response_types | array[string] | The one response type the client accepts, `code` | -| scope | string | The scope requested at authorisation, `atproto` | -| application_type | string | The client type, `web` | -| subject_type | string | The subject identifier type, `public` | -| token_endpoint_auth_method | string | The way the client authenticates at the token endpoint, `private_key_jwt` | -| token_endpoint_auth_signing_alg | string | The algorithm the client signs that assertion with, `ES256` | -| authorization_signed_response_alg | string | The algorithm the client expects a signed authorisation response in, `RS256` | -| dpop_bound_access_tokens | boolean | Whether the client binds its access tokens to a DPoP proof, always true | -| jwks_uri | string | The URL of [Get Bluesky JWKS](#get-bluesky-jwks) | - -1 Present only when the operator configured a non-empty value +Returns the instance's atproto OAuth client metadata as JSON. This endpoint must be publicly reachable for Bluesky authorisation. ### Response | Status | Body | Condition | | --- | --- | --- | -| 200 | client metadata document | Instance has a configured Bluesky OAuth client | -| 4041 | object | Instance has no configured Bluesky OAuth client | - -1 The body is `{"error": "Bluesky OAuth is not enabled"}`, so it has no `code` and no `errors` member. The client is unconfigured when the operator disabled Bluesky, when no signing key is configured, and when a configured key was rejected at load +| 200 | client metadata document | Bluesky is configured | +| 404 | `{"error": "Bluesky OAuth is not enabled"}` | Bluesky is unavailable | ### Rate limit -60 requests per minute for each client IP address, on the `connection:bluesky:client_document` bucket, which is shared with [Get Bluesky JWKS](#get-bluesky-jwks). +60 requests per minute for each client IP address, on the `connection:bluesky:client_document` bucket, shared with [Get Bluesky JWKS](#get-bluesky-jwks). ## Get Bluesky JWKS -Returns the JSON Web Key Set holding the public half of every configured Bluesky OAuth signing key. - -An atproto authorisation server reads this document, named by `jwks_uri` in [Get Bluesky client metadata](#get-bluesky-client-metadata), to verify the `private_key_jwt` assertion the instance signs at the token endpoint. The server fetches it at the code exchange that completes the flow, and again at every session refresh, long after the account holder has left the browser. - -### Response body - -| Field | Type | Description | -| --- | --- | --- | -| keys | array[object] | The public half of each configured signing key, in configuration order | - -The array holds at least one entry on any 200. Each entry has the public members of one ES256 key. There is no `alg` and no `use`, so a verifier that requires either one rejects the set. - -| Field | Type | Description | -| --- | --- | --- | -| kty | string | The key type, `EC` | -| kid | string | The identifier the operator gave the key, which the signed assertion names in its header | -| key_ops | array[string] | The operations the public key permits, `verify`, `encrypt`, and `wrapKey` | -| crv | string | The curve, `P-256` | -| x | string | The base64url encoded X coordinate | -| y | string | The base64url encoded Y coordinate | +Returns the instance's public OAuth signing keys as a JSON Web Key Set. This endpoint must be publicly reachable for Bluesky authorisation. ### Response | Status | Body | Condition | | --- | --- | --- | -| 200 | JSON Web Key Set | Instance has a configured Bluesky OAuth client | -| 4041 | object | Instance has no configured Bluesky OAuth client | - -1 The body is `{"error": "Bluesky OAuth is not enabled"}`, so it has no `code` and no `errors` member +| 200 | JSON Web Key Set | Bluesky is configured | +| 404 | `{"error": "Bluesky OAuth is not enabled"}` | Bluesky is unavailable | ### Rate limit -60 requests per minute for each client IP address, on the `connection:bluesky:client_document` bucket, which is shared with [Get Bluesky client metadata](#get-bluesky-client-metadata). +60 requests per minute for each client IP address, on the `connection:bluesky:client_document` bucket, shared with [Get Bluesky client metadata](#get-bluesky-client-metadata). diff --git a/fluxer_docs/src/content/docs/http-api/deployment-availability.md b/fluxer_docs/src/content/docs/http-api/deployment-availability.md index aa4f5f469..4c6aed8a2 100644 --- a/fluxer_docs/src/content/docs/http-api/deployment-availability.md +++ b/fluxer_docs/src/content/docs/http-api/deployment-availability.md @@ -8,7 +8,7 @@ A small set of routes exists only on the deployment Fluxer hosts. An operator ru A self-hosted deployment does not register those routes. A request to one returns 404 `NOT_FOUND` with no feature-specific code, so a caller cannot tell an unavailable route from an unrecognised path. -The API decides registration once at process start from the deployment configuration. No credential, permission, premium state, or OAuth2 scope changes the answer. A client resolves the deployment kind from instance discovery. +Credentials, permissions, premium state and OAuth2 scopes do not change route availability. Read the deployment kind from instance discovery. ## Deployment kind diff --git a/fluxer_docs/src/content/docs/http-api/discovery.mdx b/fluxer_docs/src/content/docs/http-api/discovery.mdx index 43066d77a..f1557b2fe 100644 --- a/fluxer_docs/src/content/docs/http-api/discovery.mdx +++ b/fluxer_docs/src/content/docs/http-api/discovery.mdx @@ -20,7 +20,7 @@ Searching, applying, editing, withdrawing, and joining fail with 400 `DISCOVERY_ ## Discovery guild object -One search result. Every field except `member_count` and `online_count` is read from the discovery search index. +One public guild listing. ### Structure @@ -39,16 +39,16 @@ One search result. Every field except `member_count` and `online_count` is read | features | array[string] | The [guild features](/http-api/guilds/#guild-features) the guild has | | verification_level4 | integer | The effective [verification level](/http-api/guilds/#verification-levels) of the guild | -1 These listing fields come from the guild's approved application as it stood at index time, and an entry that stores no category is reported as category `0` +1 These fields describe the approved application. An unspecified category is reported as `0` -2 Refreshed from the main Gateway at response time. When that refresh fails, the operation still succeeds and reports the indexed member count with an `online_count` of `0` +2 Counts are approximate. When live counts are unavailable, `member_count` can be stale and `online_count` is `0` 3 An animated hash retains its `a_` prefix, which is the animation indicator for this object 4 A listed guild is reported at least at level `1`, so a guild that stores level `0` is reported as `1` while it remains discoverable -:::note[Index values lag the guild record] -A guild that changed its name, icon, banner, features, or verification level since the last synchronisation is still reported with the indexed values, for up to 15 minutes. +:::note[Listing updates can take time] +Changes to a guild's name, icon, banner, features or verification level can take up to 15 minutes to appear. ::: ### Example @@ -361,7 +361,7 @@ A pending, rejected, removed, or absent application fails with 400 `DISCOVERY_NO 1 An account whose phone requirement was deferred is re-evaluated against the target guild at join time, so an account that satisfies the standing check can still be refused here :::note[A guild that never existed returns 400, not 404] -No approved application names it, so the request fails with `DISCOVERY_NOT_DISCOVERABLE` before any guild record is read. +A guild with no approved listing returns `DISCOVERY_NOT_DISCOVERABLE`. ::: ### Side effects @@ -370,7 +370,7 @@ An account that is already a member receives the same 204 response with no Dispa The joining account's sessions receive [Guild Create](/gateway/events/#guild-create). [Guild Member Add](/gateway/events/#guild-member-add) is dispatched guild-wide and reaches whichever sessions [event filtering](/gateway/event-filtering/) selects. A bot session always receives it, and a passive user session in a guild with more than 250 members does not receive it until [Lazy Request](/gateway/commands/#lazy-request) marks that guild active. The joining account receives [User Settings Update](/gateway/events/#user-settings-update) when the join changes its restricted guild set or its folder layout, and [User Guild Settings Update](/gateway/events/#user-guild-settings-update) when its account default hides muted channels. -Unless join notifications are suppressed or the guild stores no system channel, the operation creates a [USER_JOIN](/http-api/messages/#message-types) system message in that channel and delivers [Message Create](/gateway/events/#message-create). The new member is indexed for [Guild member search](/http-api/guild-member-search/) only in a guild whose member list has already been indexed. No invite use is consumed. +Unless join notifications are suppressed or no system channel is configured, the join emits a [USER_JOIN](/http-api/messages/#message-types) system message through [Message Create](/gateway/events/#message-create). No invite use is consumed. ### Rate limit @@ -401,7 +401,7 @@ Its submission time is also its review time, it gains the discoverable feature, | primary_language?3 | string | The [supported primary language](#supported-primary-languages) code of the listing (default en-US) | | custom_tags?4 | array[string] | The [custom tags](#custom-tags) to store (at most 10) | -1 Scanned against the instance content blocklists before the guild is read and before the eligibility and duplicate checks run, and a match returns 403 `CONTENT_BLOCKED` +1 A value blocked by instance content policy returns 403 `CONTENT_BLOCKED` 2 A value outside 0 through 8, a negative value, or a non-integer value is rejected by body validation @@ -428,7 +428,7 @@ Its submission time is also its review time, it gains the discoverable feature, The submitted listing replaces a previous rejected or removed application. -A pending application changes no guild feature, does not appear in search, and emits no Dispatch. An automatic approval adds the discoverable feature, indexes the listing so that [Search discovery guilds](#search-discovery-guilds) returns it, and delivers [Guild Update](/gateway/events/#guild-update) to every session that can see the guild. +A pending application stays out of search and emits no Dispatch. Automatic approval makes the guild discoverable and sends [Guild Update](/gateway/events/#guild-update) to every session that can see it. ### Rate limit @@ -457,7 +457,7 @@ Every field is optional, and an omitted field preserves the stored value. | primary_language?3 | string | The [supported primary language](#supported-primary-languages) code of the listing | | custom_tags?4 | array[string] | The [custom tags](#custom-tags) to store (at most 10) | -1 A supplied value is scanned against the instance content blocklists before the stored application is read, and a match returns 403 `CONTENT_BLOCKED` +1 A value blocked by instance content policy returns 403 `CONTENT_BLOCKED` 2 A value outside 0 through 8 is rejected by body validation @@ -483,7 +483,7 @@ Every field is optional, and an omitted field preserves the stored value. ### Side effects -The supplied fields update the listing while preserving its status, submission time, review time, and review reason. Editing an approved listing reindexes its result for [Search discovery guilds](#search-discovery-guilds). A pending listing remains absent from search. No guild feature changes. +Editing preserves the listing's status and review details. Approved changes appear in [Search discovery guilds](#search-discovery-guilds), while pending listings remain absent. No guild feature changes. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/donations.mdx b/fluxer_docs/src/content/docs/http-api/donations.mdx index 074e862bb..560a68c3c 100644 --- a/fluxer_docs/src/content/docs/http-api/donations.mdx +++ b/fluxer_docs/src/content/docs/http-api/donations.mdx @@ -13,7 +13,7 @@ None of the routes here takes a credential, and all are hosted-only, as [deploym Fluxer uses a submitted address exactly as written and matches a donor by exact equality, so `Donor@example.com` and `donor@example.com` address two different donors. :::note[Donation management requires a completed donation] -The payment provider webhook writes the donor record, and nothing else writes one. +Donation management becomes available after payment is confirmed. ::: ## Donation currencies @@ -85,7 +85,7 @@ An address that resolves to no donor receives no email. 2 A padded address fails syntax validation -3 The domain is resolved by MX record first, and by A or AAAA record only when it publishes no MX record. A transient resolver failure counts as valid. Fluxer caches the result for 30 minutes when valid and 5 minutes when not +3 The domain must publish an MX, A or AAAA record ### Response @@ -96,7 +96,7 @@ An address that resolves to no donor receives no email. ### Side effects -Everything happens before the response. Fluxer resolves the address domain, looks up the donor, and, when a donor exists, creates a token and sends the email. A delivery failure is absorbed. An address held as hard bounced and a transport error both leave the token stored and still answer 204. +An eligible donor receives an email. The 204 response does not guarantee delivery. Fluxer creates a single-use token of 64 lowercase hexadecimal characters, valid for 15 minutes, only when the address resolves to a donor. Issuing a new link deletes every earlier token for the same address, and a replaced link returns 400 `DONATION_MAGIC_LINK_INVALID`. @@ -110,7 +110,7 @@ Fluxer creates a single-use token of 64 lowercase hexadecimal characters, valid Consumes a donation management token. Answers 302 with `Location` set to the externally hosted billing portal for the matching donor. The token is the credential. -Fluxer checks the token in a fixed order. An unknown token returns 400 `DONATION_MAGIC_LINK_INVALID`. A token past its 15 minute lifetime returns 400 `DONATION_MAGIC_LINK_EXPIRED`. An already consumed token returns 400 `DONATION_MAGIC_LINK_USED`, and a token that is both expired and consumed is reported as expired. None of those refusals consumes the token. +An unknown token returns 400 `DONATION_MAGIC_LINK_INVALID`, an expired token returns `DONATION_MAGIC_LINK_EXPIRED`, and a used token returns `DONATION_MAGIC_LINK_USED`. An expired and used token reports `DONATION_MAGIC_LINK_EXPIRED`. A token whose address no longer resolves to a donor holding a payment provider customer is still consumed, and the redirect goes to the public donation page. For a donor holding a customer, a deployment with no configured payment provider returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` and a provider failure returns 400 `STRIPE_ERROR`, both after the token has already been consumed. @@ -147,7 +147,7 @@ Creates a one-off or recurring donation checkout session. Returns a [donation ch An amount outside the selected currency's bounds fails validation with 400 `INVALID_FORM_BODY` and an `errors` entry on `amount_cents`. Where the deployment has no configured payment provider, the route returns 400 `STRIPE_PAYMENT_NOT_AVAILABLE` before it resolves the address. A provider failure while creating the session returns 400 `STRIPE_ERROR`. -A recurring donation for an address that already holds an active recurring donation returns the public donation management page for that address, with the percent-encoded address in `email` and the literal value `active_subscription` in `alert`. An address holds an active recurring donation when its donor record has a subscription identifier, a current period end still in the future, and no scheduled cancellation. +A recurring donation for an address with an active recurring donation returns the public donation management page, with the percent-encoded address in `email` and `active_subscription` in `alert`. A donation scheduled for cancellation does not block a new checkout. ### JSON body @@ -174,11 +174,9 @@ A recurring donation for an address that already holds an active recurring donat ### Side effects -The operation creates an externally hosted checkout for the submitted amount, currency, and interval. Tax identifier collection is enabled and automatic tax is disabled. A recurring donation uses subscription mode, and a one-off donation uses payment mode with invoice creation. The session returns the donor to the public donation success page once it completes, and to the public donation page if it is cancelled. +Checkout opens on the provider's pages for the submitted amount, currency and interval. Completion returns the donor to the public donation success page, while cancellation returns to the public donation page. -The session records that it is a donation, the submitted address, whether the donation is one-off or recurring, and the business flag. Fluxer reuses an existing donor's payment provider customer when one exists, and otherwise the provider creates one during checkout. - -This operation writes nothing to the donor record. [Receive Stripe webhook](/http-api/billing/#receive-stripe-webhook) creates or updates the donor record on completion, makes the address eligible for [Request donation management link](#request-donation-management-link), and sends a confirmation email. +Payment confirmation sends an email and enables [Request donation management link](#request-donation-management-link). ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/downloads.mdx b/fluxer_docs/src/content/docs/http-api/downloads.mdx index a7f180dec..c0dbe5c49 100644 --- a/fluxer_docs/src/content/docs/http-api/downloads.mdx +++ b/fluxer_docs/src/content/docs/http-api/downloads.mdx @@ -1,7 +1,7 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Desktop downloads -description: Desktop release metadata, artifact and checksum downloads, test builds, and the storage catch-all. +description: Desktop release metadata, artifact and checksum downloads, test builds, and release feeds. --- import RouteHeader from '@/components/RouteHeader.astro'; @@ -15,7 +15,7 @@ Fluxer registers the routes under `/dl`, and the desktop routes under `/dl/deskt The prefix mounts at the root and at `/v1`, so `/v1/dl/desktop/...` resolves for the routes that name their own segments. :::caution[The catch-all has no `/v1` form] -[Download stored object](#download-stored-object) builds its storage key from the raw request path and keeps only a path beginning `/dl`. A request to `/v1/dl/...` returns 404 while the same path without that prefix returns the object. +[Download stored object](#download-stored-object) requires the `/dl` prefix. Its `/v1/dl/...` equivalent returns 404. ::: Every other route on this reference takes the `/v1` form, and [HTTP API](/http-api/) states that a client MUST use it. @@ -28,7 +28,7 @@ A `HEAD` on either JSON route runs the same resolution as the `GET` and returns A checksum route answers a `HEAD` with 200 and no body. The headers are `Content-Type`, `Content-Disposition`, `Cache-Control`, and the `Content-Length` of the checksum line. -The artifact routes and the catch-all read object metadata for a `HEAD`. They return 200 with `Content-Type`, `Content-Disposition`, `Accept-Ranges`, `Cache-Control`, `Content-Length`, and, where storage reports them, `ETag` and `Last-Modified`. They ignore `Range`, answer neither 206 nor 416, and never redirect to a [presigned storage URL](#redirects). Fluxer resolves a [country redirect](#redirects) before it reads the method, so a `HEAD` receives that 302 exactly as a `GET` does. +Artifact and catch-all `HEAD` requests return 200 with `Content-Type`, `Content-Disposition`, `Accept-Ranges`, `Cache-Control` and `Content-Length`, plus `ETag` and `Last-Modified` when available. They ignore `Range` and never return 206, 416 or a [presigned URL redirect](#redirects). [Country redirects](#redirects) still return 302 for `HEAD`, as for `GET`. ## Release channels @@ -37,7 +37,7 @@ The artifact routes and the catch-all read object metadata for a `HEAD`. They re | stable | Stable | The channel a deployment publishes for general use | | canary | Canary | The channel that receives a build ahead of `stable` | -The channel selects the storage prefix and the product name in a filename. A `canary` file is named `Fluxer-Canary` or `Fluxer Canary`, and a `stable` file is named `Fluxer`. +The channel selects the release series and product name. A `canary` file is named `Fluxer-Canary` or `Fluxer Canary`, and a `stable` file is named `Fluxer`. ## Platforms and architectures @@ -79,13 +79,7 @@ On `darwin` a `dmg` or `zip` request tries the `universal` file before the archi A client appends `.sha256` to the format segment to read the checksum, and sends the bare format to read the file. `tar_gz.sha256` is the checksum of `tar_gz`. -The checksum route claims any last segment matching `[a-z_]+\.sha256` before the artifact route sees it. - -:::note[The constrained route claims the segment first] -A segment ending in `.sha256` that names no [package format](#package-formats), such as `exe.sha256`, reaches the checksum route and fails there with 400 `INVALID_FORM_BODY`. -::: - -The pattern is lowercase, so `appimage.SHA256` matches neither checksum route. It reaches the artifact route, where the format schema rejects it with the same status and code. +Use the lowercase `.sha256` suffix with a valid [package format](#package-formats). An invalid format or suffix returns 400 `INVALID_FORM_BODY`. ## Version info object @@ -100,9 +94,9 @@ A version info object describes one release at a coordinate and the file it has | minimum_system_version?2 | ?string | The lowest operating system version the release supports | | files3 | map[string, [version file](#version-file-object) object] | The download entry for each [package format](#package-formats) the release has | -1 Copied from the coordinate's manifest without reformatting. A coordinate with no usable manifest reports the newest storage modification time among the objects of that release instead +1 Publication time reported for the release -2 Present only where the coordinate has a manifest and that manifest names a value +2 Present when the release specifies a minimum system version 3 A format that resolves no file is absent from the map @@ -137,7 +131,7 @@ A version file object is the download entry for one format of one release. 1 Built from `api_client` in the [instance endpoints object](/http-api/instance/#instance-endpoints-object), with no `/v1` segment -2 Read from the sibling `.sha256` object in storage, or from the manifest entry where the manifest named the file that resolved. A value outside `^[a-f0-9]{64}$` is reported as null +2 A 64-character lowercase hexadecimal hash, or null when no valid checksum is available ## Checksum files @@ -149,19 +143,11 @@ A checksum response is one `sha256sum` line: the hash, two spaces, the resolved The response has `Content-Type: text/plain; charset=utf-8`, a `Content-Disposition` of `attachment` naming the resolved filename with `.sha256` appended, and a `Content-Length` counting the encoded line. It sets no `Accept-Ranges`, `ETag`, or `Last-Modified`, and it ignores a `Range` header. -Fluxer reads the hash from the sibling `.sha256` object or from the manifest entry, and treats a value that is not 64 lowercase hexadecimal characters as absent. That case returns 404. +A missing or invalid checksum returns 404. ## Artifact resolution -Fluxer resolves a coordinate to one storage key before it answers. It reads `manifest.json` under the coordinate prefix first and takes the filename that manifest records for the requested format. Fluxer falls back to a listing when that manifest is absent, is not valid JSON, is not a manifest object, describes another coordinate, names a file storage does not hold, or names a version that is not releasable. The fallback lists the objects under the prefix and takes the highest releasable version whose filename parses for the requested format. - -The listing skips any name containing `/` and any name ending in `.sha256`, `.blockmap`, or `.yml`. It also skips `manifest.json`, `RELEASES.json`, and `releases.json`. - -A version with no release descriptor at `desktop/{channel}/github-releases/{version}.json` is releasable. A version with a descriptor is releasable only when its readiness marker, `{version}.ready.json` in the same directory, matches the descriptor. The marker matches when its `channel`, `version`, and `release_tag` are the expected values and its `descriptor_sha256` is the SHA-256 of the stored descriptor file. - -The fallback checks at most ten versions, newest first. When none of the ten is releasable, it takes the newest of them. Fluxer treats a storage error on the descriptor or the marker as releasable. The check does not apply under `desktop-test/` or on a self-hosted instance. The versioned routes do not check releasability and resolve any stored version. - -A coordinate that resolves no file returns 404 with the plain text body `Not Found`. +Use `latest` for the release currently offered at a channel, platform and architecture, or a versioned URL to request a specific release. A missing file returns 404 with the plain text body `Not Found`. ## Response headers and caching @@ -187,7 +173,7 @@ A release feed filename is any of these: The deployment settings below answer a download with 302. -A deployment that reports `self_hosted` false on the [instance features object](/http-api/instance/#instance-features-object) and configures a country list for GitHub redirects resolves the caller's country on every `desktop/` artifact request. When a complete and verified release descriptor names the file, a caller in a listed country receives 302 to that GitHub release asset. Every other outcome streams from storage, and every response under this setting has `Cache-Control: private, no-store`. +A hosted deployment can redirect downloads from selected countries to GitHub release assets. Responses under this setting have `Cache-Control: private, no-store`. A deployment that issues presigned download URLs answers a `GET` with 302 to a storage URL valid for 900 seconds. That redirect has `Cache-Control: no-store` and `Accept-Ranges: bytes`. The setting is off by default. @@ -197,7 +183,7 @@ The client follows `Location` to read the file. Fluxer produces the redirect bef ## Test builds -Every route accepts `test` as a query parameter. The value `1` or `true`, matched without regard to case, resolves the object against the `desktop-test/` storage prefix. Any other value is read as false. +Every route accepts the `test` query parameter. `1` or `true`, matched without regard to case, selects test releases. Any other value is false. On the desktop routes the flag replaces the prefix outright. On [Download stored object](#download-stored-object) it rewrites a key beginning `desktop/` and leaves any other key unchanged, and a path that already names `desktop-test/` resolves there with no flag at all. @@ -282,9 +268,7 @@ Returns the releases stored at the coordinate, newest first. This route never answers 404. A coordinate that holds no object returns 200 with an empty array. -Fluxer builds the listing from storage objects and ignores the manifest, so a release the manifest names but storage does not hold is absent here. `pub_date` reports the newest storage modification time among that release's objects, and `minimum_system_version` is never reported. - -A format is listed only where a filename under the coordinate parses for it, and `sha256` is reported only where the sibling `.sha256` object exists and holds a valid hash. +Only available files are listed. This response omits `minimum_system_version` and reports `sha256` only when a valid checksum is available. ### Response headers @@ -418,8 +402,6 @@ Streams one released file by version and package format. | 404 | `Not Found` | No file resolved for the version and format | | 416 | empty | The requested range is unsatisfiable | -Fluxer reads the manifest only where the manifest names the requested version. Otherwise it tries the known filename spellings for the coordinate in turn and streams the first one storage holds, so a version published under an older naming scheme still resolves. - ### Response headers The 200 has `Content-Type`, `Content-Disposition`, `Content-Length`, `Accept-Ranges: bytes`, `Cache-Control`1, `ETag`2, and `Last-Modified`2. The 206 has the 200 headers plus `Content-Range`. The 302 has `Location`, `Accept-Ranges: bytes`, and `Cache-Control`. The 404 has `Content-Type: text/plain`. The 416 has `Accept-Ranges: bytes`, `Content-Range: bytes */{size}`, and `Cache-Control`. @@ -460,7 +442,7 @@ Returns the [checksum file](#checksum-files) for one released file. | 400 | [error response](/http-api/#error-response) | A path or query value fails its schema and the request returns `INVALID_FORM_BODY` | | 404 | `Not Found` | No file resolved for the version and format, or no valid hash was found for it | -Fluxer reads the hash from the sibling `.sha256` object for the resolved file. Where that object is absent and `manifest.json` names the requested version, Fluxer uses the manifest hash for that format instead. The [releasable](#artifact-resolution) check does not apply here. Any other case returns 404. +Returns 404 when the requested file has no available checksum. ### Response headers @@ -472,13 +454,13 @@ The 200 has `Content-Type: text/plain; charset=utf-8`, `Content-Disposition`, `C -Streams one stored object addressed by its storage key. A release feed file has no format segment and no version segment, so this is the only route that reaches one. +Downloads a file by path. Use this route for release feeds, which have no format or version segment. ### Path parameters | Field | Type | Description | | --- | --- | --- | -| path1 | string | The storage key under the downloads bucket, which can contain `/` separators | +| path1 | string | The download path, which can contain `/` separators | 1 Taken from the request path with the `/dl` prefix removed, then normalised. It begins `desktop/` or `desktop-test/` after normalisation, and every other key returns 404 @@ -506,12 +488,12 @@ Streams one stored object addressed by its storage key. A release feed file has | 416 | empty | The requested range is unsatisfiable | :::caution[The catch-all reaches the permitted prefixes only] -A key that does not begin `desktop/` or `desktop-test/` returns 404 before Fluxer reads storage. +A path outside `desktop/` or `desktop-test/` returns 404. ::: Fluxer also rejects the key when it is empty, when normalisation leaves it beginning `..` or `/`, or when any segment is `.`, `..`, or contains a NUL character. Each of those returns the same 404, so a caller cannot tell a traversal attempt from a missing object. -A key can have the platform and architecture in one hyphenated segment, as in `desktop/stable/linux-x64/manifest.json`. Fluxer also tries that key with the segment split, as `desktop/stable/linux/x64/manifest.json`. It reads the requested form first and streams whichever of the two storage holds. +Both `desktop/stable/linux-x64/manifest.json` and `desktop/stable/linux/x64/manifest.json` path forms are supported. ### Response headers diff --git a/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx b/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx index 3efff86d2..058b58a9f 100644 --- a/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx +++ b/fluxer_docs/src/content/docs/http-api/entrance-sounds.mdx @@ -69,7 +69,7 @@ One stored audio clip owned by one account. Every field except `name` is fixed a | url3 | string | The absolute CDN URL the clip is fetched from | | created_at | ISO8601 timestamp | The time the clip was uploaded | -1 The leading 16 hexadecimal characters of the MD5 digest of the decoded bytes, so two clips with identical audio have an identical `hash` +1 A content hash. Clips with identical audio have the same `hash` 2 Measured from the decoded audio and rounded to the nearest millisecond @@ -124,8 +124,8 @@ A scope with no assigned sound has no selection object. Clearing a scope deletes `scope_id` must match one of these forms exactly. Any other value fails with the validation code `INVALID_FORMAT` at the `scope_id` path. -:::note[Fluxer stores scopes but never resolves them] -A selection records which sound belongs to which scope and nothing more. [Play entrance sound](#play-entrance-sound) names the sound explicitly, so the client decides which selection applies to a channel. +:::note[Clients select the sound to play] +Use the scope selections to choose a clip, then pass its ID to [Play entrance sound](#play-entrance-sound). ::: ## List entrance sounds @@ -174,7 +174,7 @@ Invisible characters in `name` are not stripped and count towards the 32 charact ### Validation -Every failure below is a 400 `INVALID_FORM_BODY`. One response can report two of the first four rows when both fields are bad. The remaining rows are reported one at a time, in the order the table lists them. +Every failure below returns 400 `INVALID_FORM_BODY`. A response can contain more than one field error. | Failure | Validation code | Path | | --- | --- | --- | @@ -207,8 +207,8 @@ The validation entry has no member naming which duration bound the clip crossed. The operation assigns no scope and emits no Gateway Dispatch. -:::note[Identical audio shares one stored object] -Uploading the same bytes twice creates a second entry with the same `hash`, so both entries have the same `url`. Fluxer deletes the stored object only when the last entry that references it is removed. +:::note[Duplicate audio can share a URL] +Uploading the same bytes twice creates distinct library entries with the same `hash` and `url`. Deleting one leaves the other usable. ::: ### Rate limit @@ -274,7 +274,7 @@ Deletes one clip from the current account's library, clears every scope selectio ### Side effects -Fluxer removes every selection naming the clip first, then the library entry, then the stored audio object unless another clip in the library has the same `hash` and `extension`. A storage failure is logged, and the request still returns 204, so the entry is removed either way. Fluxer emits no Gateway Dispatch. +The clip and every selection naming it are removed from the library. No Gateway Dispatch is emitted. ### Rate limit @@ -295,7 +295,7 @@ Assigns one of the account's clips to one [scope](#entrance-sound-scopes), or cl 1 Required. An explicit null clears the scope, succeeds even when the scope had no selection, and looks up no clip -Fluxer accepts a `guild:{guild_id}` scope on its shape alone. It neither resolves the guild nor checks membership, so a selection can name a guild the account has left. A non-null `sound_id` must name a clip the account owns, and Fluxer writes the scope only after that lookup succeeds. +A `guild:{guild_id}` scope does not require current guild membership. A non-null `sound_id` must name a clip the account owns. ### Response @@ -318,7 +318,7 @@ Fluxer writes or removes the scope's selection. One clip can be selected in any Fans the caller's chosen clip out to everyone else connected to a voice channel and returns 204 with an empty body. The caller must already hold a voice state in that channel and must own the clip. Emits an [ENTRANCE_SOUND_PLAY](/gateway/events/#entrance-sound-play) Gateway event. -Nothing is published to the media servers. Each recipient receives a Dispatch naming the clip's CDN URL, then fetches and plays it locally. +Recipients fetch and play the clip locally from the URL in the event. ### Path parameters diff --git a/fluxer_docs/src/content/docs/http-api/errors.md b/fluxer_docs/src/content/docs/http-api/errors.md index 4bafcabc6..355ee656a 100644 --- a/fluxer_docs/src/content/docs/http-api/errors.md +++ b/fluxer_docs/src/content/docs/http-api/errors.md @@ -34,9 +34,7 @@ The error code determines which supplementary members a failure has, and most co Fluxer answers a field-level failure with 400 and a top-level `errors` array. Each element identifies one failed input field. The [validation error object](/http-api/#validation-error-object) documents the element shape. -A boundary schema validates one of the request targets: the JSON body, the form body, the query string, and the path parameters. A failure on any of them returns the top-level code `INVALID_FORM_BODY`, and each element has a `code` drawn from the [validation error code registry](#validation-error-code-registry) together with a localised `message`. - -An operation can also report against a named field without the boundary schema. That failure returns `INVALID_FORM_BODY` as well. Its element has an enumerated `code` and localised `message` when the failure declares a registry code. Otherwise it has a fixed English `message` written at the failure site and no `code`. Every element `code` a client observes is a registry value. +Invalid JSON bodies, form bodies, query strings and path parameters return `INVALID_FORM_BODY`. Each entry in `errors` identifies a field and its message. An entry can also include a stable `code` from the [validation error code registry](#validation-error-code-registry). Entries without a code have an English message. [Modify current user settings](/http-api/users/settings/#modify-current-user-settings) is the one operation that answers such a failure with the top-level code `VALIDATION_ERROR` instead of `INVALID_FORM_BODY`. Its trusted domain, age restriction, and synced preferences decisions each report one element with a registry `code` and a fixed English `message`. @@ -112,14 +110,10 @@ Fluxer answers an unrecognised failure with 500 `INTERNAL_SERVER_ERROR` and a ge ## Client errors as an abuse signal -Every `4xx` response to a request that resolved no authenticated user contributes a weighted abuse signal keyed by the client IP identity. A 429 weighs 3, a 401 weighs 0.75, a 403 weighs 0.5, and every other 4xx weighs 0.25. Fluxer records a signal only when the client address is public and not exempt. An IPv4 client is keyed by its exact address and an IPv6 client by its `/64`. One request records at most one client error signal. A denial produced by an existing IP ban records no signal. - -Fluxer records a second signal of weight 1 for a credential that fails to resolve. A request that presents an unrecognised token and is answered 401 contributes both. That signal also records a hash of the credential presented, and Fluxer tracks the number of distinct hashes seen for one IP identity beside the score. - -The score and the credential hashes accumulate inside a fixed window, and the window restarts once it elapses. The triggers below fire an automatic ban. The credential trigger fires the first time the count of distinct rejected credentials reaches its threshold. The score trigger fires only after the score has crossed its threshold in three separate windows, which is the default. Both thresholds depend on the address classification, which is datacentre, anonymising, mobile, or residential. An unclassified address takes the residential thresholds. Fluxer never bans a mobile address automatically. +Repeated invalid requests or credentials can trigger a temporary IP ban. A `4xx` answer to a request with no authenticated user adds to that signal, weighted by status. A 429 weighs 3, a 401 weighs 0.75, a 403 weighs 0.5, and every other 4xx weighs 0.25. One request adds at most one signal, and a request from a private or exempt address adds none. Stop using a rejected credential and respect rate-limit responses instead of retrying unchanged requests. :::caution[An automatic ban answers every request for 24 hours] -The window length, both thresholds, and the number of windows the score trigger requires are instance configuration. A tripped ban lasts 24 hours by default. While it holds, Fluxer answers every request from that identity with 403 and the code `GLOBAL_IP_TEMPORARILY_BANNED`. +A temporary ban lasts 24 hours by default. Requests from the banned address return 403 `GLOBAL_IP_TEMPORARILY_BANNED`. Use `expires_at` from the response when available. ::: ## API error code registry @@ -186,18 +180,10 @@ Bad request We couldn't resolve that Bluesky handle -### `BLUESKY_OAUTH_CALLBACK_FAILED` - -We couldn't complete the Bluesky connection - ### `BLUESKY_OAUTH_NOT_ENABLED` Bluesky connections are not enabled on this instance -### `BLUESKY_OAUTH_STATE_INVALID` - -The authorization request has expired or is invalid - ### `BOTS_CANNOT_CREATE_GUILDS` Bots can't create communities @@ -1405,7 +1391,7 @@ Discoverable communities must have a verification level of at least Low ### `DISCRIMINATOR_INVALID_FORMAT` -Discriminator must be {min}–{max} digits +`Discriminator must be {min}–{max} digits` ### `DISCRIMINATOR_OUT_OF_RANGE` @@ -2130,10 +2116,6 @@ Webhook name must be between {min} and {max} characters ## Localisation -An error `message` is rendered in one resolved locale. Fluxer uses the configured locale of the authenticated account when the request is authenticated and the account has one. Otherwise it negotiates the request `Accept-Language` value against the canonical [supported locale registry](/topics/locales/#supported-locales). [Locales](/topics/locales/#negotiation) defines the resolution algorithm, including weights, language subtag reduction, and the `en-US` result. +Messages use the account's locale or the request's `Accept-Language` header, with an English fallback. Early rejections such as IP bans can use the header rather than the account locale. See [Locales](/topics/locales/). -A failure raised before the request locale is resolved, such as an IP ban denial, cannot see the account locale and negotiates `Accept-Language` on its own. That negotiation reads the header entries in order, ignores quality weights, and falls back to `en-US`. - -Fluxer localises the `message` of a validation element that has a `code` the same way. A validation element with no `code` has the fixed English string written at the failure site. The `code` field is never localised, either in the envelope or in a validation element. - -A code whose catalogue entry is missing for the resolved locale falls back to its English source template. Where no template is registered at all, the `message` falls back to the one the failure supplied, or to the code itself. +The `code` field is never translated. Validation entries without a code have an English message. diff --git a/fluxer_docs/src/content/docs/http-api/experiments.mdx b/fluxer_docs/src/content/docs/http-api/experiments.mdx index 872f57e22..c3af31c8b 100644 --- a/fluxer_docs/src/content/docs/http-api/experiments.mdx +++ b/fluxer_docs/src/content/docs/http-api/experiments.mdx @@ -22,7 +22,7 @@ One resolution of every defined experiment against one account. Every field is p | poll_jitter_percent | integer | How far to spread the wait around the interval, from 0 through 50 | | assignments | [assignment map](#assignment-map-object) object | One entry for each experiment the server defines | -The polling fields sit on the envelope rather than on any one experiment, because the cadence is a property of the route and not of a rollout. They are the operator's [experiment delivery configuration](/admin-api/instance/#experiment-delivery-configuration-object) read back unchanged, so they hold the same values for every account and every experiment, whatever those experiments resolve to. +The polling fields apply to every experiment and account on the instance. ## Assignment map object @@ -34,7 +34,7 @@ One entry per experiment. The envelope reports this object even when it is empty | --- | --- | --- | | voice_noise_suppression? | [noise suppression assignment](#noise-suppression-assignment-object) object | The caller's noise suppression assignment | -Every key here is optional in the schema so that a peer running a different server version still validates the envelope. A client reading a server that has not defined an experiment it knows about, or that defines one it does not, drops the unknown key and treats the missing one as off rather than as an error. +Ignore unknown experiments and treat a missing experiment as off. This server version writes `voice_noise_suppression` on every response, including while the rollout is disabled. The disabled value is the first [resolution outcome](#resolution-outcomes) below, which reports `enabled` false and the stored `config_version`, so a client can tell an operator write from a no-op without a second request. @@ -73,7 +73,7 @@ One resolution of the instance noise suppression rollout against one account. Ev | stereo_enabled | boolean | Whether the client publishes a stereo microphone track | | suppression_strength | integer | Suppression strength from 0 through 100, which only a backend that reads it applies | -`config_version` counts operator writes, not assignment changes. It is raised by every [Update instance configuration](/admin-api/instance/#update-instance-configuration) request that sets at least one noise suppression field, so it can advance while the caller's assignment stays byte for byte the same. +`config_version` identifies the configuration revision. It can change without changing the caller's assignment. ### Resolution outcomes @@ -135,7 +135,7 @@ A client sends the `ETag` it last received. Fluxer compares it against the tag o | Cache-Control | string | The literal value `private, no-cache` | | Vary | string | The literal value `Authorization`, replaced by `Origin` where the [cross-origin policy](/http-api/#cross-origin-requests) echoed an allowed origin | -The tag is a hash of the body alone, so two accounts resolving to the same envelope receive the same tag. It changes whenever any field changes, the polling fields and `config_version` included. +The `ETag` changes when the response changes. Treat it as an opaque value. `ETag` is listed in `Access-Control-Expose-Headers` and `If-None-Match` in `Access-Control-Allow-Headers`, so a cross-origin client reads the tag and revalidates with it. @@ -143,7 +143,7 @@ The tag is a hash of the body alone, so two accounts resolving to the same envel A client reads this route once per session and then again every `poll_interval_seconds`, offset by a random amount up to `poll_jitter_percent` of that interval in either direction. It sends the last `ETag` on every request after the first. A client MUST NOT poll faster than the lower end of that jitter range. -Raising `poll_interval_seconds` sheds request volume. Raising `poll_jitter_percent` spreads a fleet that has synchronised on one interval. A client that has never reached this route holds the built-in defaults of 300 seconds and 15 percent, so neither value reaches a client that cannot read it. +Until the first successful response, use 300 seconds with 15 percent jitter. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/expressions.mdx b/fluxer_docs/src/content/docs/http-api/expressions.mdx index 27b887d35..e134c871a 100644 --- a/fluxer_docs/src/content/docs/http-api/expressions.mdx +++ b/fluxer_docs/src/content/docs/http-api/expressions.mdx @@ -118,8 +118,8 @@ Returns the [emoji metadata object](#emoji-metadata-object) of any guild emoji. 1 The [error code](/http-api/errors/) is `UNKNOWN_EMOJI` for a missing emoji and `UNKNOWN_GUILD` when the emoji record survives but its guild does not -:::caution[Cloning reads the emoji's guild again] -[Clone guild emoji](/http-api/guild-emojis/#clone-guild-emoji) resolves the source emoji and the source guild again. When the source guild drops `CLONE_EMOJI_ENABLED` between the two calls, the clone fails with 403 `MISSING_ACCESS`. +:::caution[Cloning permission can change] +[Clone guild emoji](/http-api/guild-emojis/#clone-guild-emoji) returns 403 `MISSING_ACCESS` if the source guild no longer allows cloning. ::: ### Rate limit @@ -147,8 +147,8 @@ Returns the [sticker metadata object](#sticker-metadata-object) of any guild sti 1 The [error code](/http-api/errors/) is `UNKNOWN_STICKER` for a missing sticker and `UNKNOWN_GUILD` when the sticker record survives but its guild does not -:::caution[Cloning reads the sticker's guild again] -[Clone guild sticker](/http-api/guild-stickers/#clone-guild-sticker) resolves the source sticker and the source guild again. When the source guild drops `CLONE_STICKER_ENABLED` between the two calls, the clone fails with 403 `MISSING_ACCESS`. +:::caution[Cloning permission can change] +[Clone guild sticker](/http-api/guild-stickers/#clone-guild-sticker) returns 403 `MISSING_ACCESS` if the source guild no longer allows cloning. ::: ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/gateway.mdx b/fluxer_docs/src/content/docs/http-api/gateway.mdx index 99d760c28..694afc689 100644 --- a/fluxer_docs/src/content/docs/http-api/gateway.mdx +++ b/fluxer_docs/src/content/docs/http-api/gateway.mdx @@ -6,9 +6,9 @@ description: Bot discovery of the main Gateway endpoint, shard count, and sessio import RouteHeader from '@/components/RouteHeader.astro'; -Gateway information tells a bot where to open its connection to the [main Gateway](/gateway/overview/). The object has that URL, a shard count, and a session start budget. Framing, opcodes, heartbeats, resumption, and close codes belong to the [Gateway overview](/gateway/overview/). +This endpoint returns the bot Gateway URL, recommended shard count and advertised session start limits. See the [Gateway overview](/gateway/overview/) for the WebSocket protocol. -The one route here reads `Authorization` itself. Only the [bot token](/authentication/#bot-tokens) form is accepted. A user client instead reads the same WebSocket URL from `endpoints.gateway` in the [instance discovery document](/http-api/instance/#instance-endpoints-object) and presents its session token inside [Identify](/gateway/commands/#identify). +Use a [bot token](/authentication/#bot-tokens). User clients read `endpoints.gateway` from [instance discovery](/http-api/instance/#instance-endpoints-object) and send their session token in [Identify](/gateway/commands/#identify). ## Gateway information object @@ -18,15 +18,9 @@ Every field is always present. | Field | Type | Description | | --- | --- | --- | -| url1 | string | The `ws` or `wss` URL of the main Gateway endpoint | -| shards2 | integer | The shard count recommended for the authenticated bot | -| session_start_limit3 | [session start limit](#session-start-limit-object) object | The advertised session start budget | - -1 The deployment's configured Gateway endpoint, byte for byte the string [instance endpoints](/http-api/instance/#instance-endpoints-object) publishes as `endpoints.gateway` - -2 Always the literal `1` - -3 Every member of the object is a constant +| url | string | The `ws` or `wss` URL, identical to `endpoints.gateway` in [instance discovery](/http-api/instance/#instance-endpoints-object) | +| shards | integer | Recommended shard count, always `1` | +| session_start_limit | [session start limit](#session-start-limit-object) object | Fixed session start values | Fluxer appends no query string, so a client appends the [connection parameters](/gateway/overview/#connection-parameters) itself. A client MUST NOT upgrade a published `ws` value, because a deliberately plain HTTP deployment advertises one. @@ -47,33 +41,25 @@ Fluxer appends no query string, so a client appends the [connection parameters]( [Identify](/gateway/commands/#identify) accepts a `shard` pair whose `shard_count` element is from 1 through 16,384 under the [sharding](/gateway/overview/#sharding) contract, whatever `shards` reports. A bot session whose shard is assigned more than 2,500 guilds closes with `4011`, and the bot must shard further. A user session is never checked against the guild ceiling. -:::note[The response is currently static] -Fluxer resolves only `url` from deployment configuration. `shards` and `session_start_limit` are fixed values that do not track the bot's guild count. -::: - ## Session start limit object -A session start limit reports how many new Gateway sessions a bot may open in a window. Fluxer publishes constants here for client-library compatibility. +These are fixed compatibility values, not live usage counters. ### Structure | Field | Type | Description | | --- | --- | --- | -| total1 | integer | The session starts allowed across the window, always `1000` | -| remaining1 | integer | The session starts reported as left, always `999` | -| reset_after2 | integer | The milliseconds reported until the budget resets, always `14400000` | -| max_concurrency3 | integer | The number of concurrent [Identify](/gateway/commands/#identify) buckets, always `1` | +| total | integer | Reported session starts allowed, always `1000` | +| remaining | integer | Reported session starts remaining, always `999` | +| reset_after | integer | Reported reset interval in milliseconds, always `14400000` (four hours) | +| max_concurrency1 | integer | Reported concurrent [Identify](/gateway/commands/#identify) buckets, always `1` | -1 Fluxer keeps no session-start ledger, so neither a request nor an Identify changes either value - -2 14400000 ms is four hours, and Fluxer runs no timer against it - -3 Fluxer runs no Identify concurrency bucket, so a bot MAY identify its shards without pacing them against this value +1 A bot does not need to pace Identify requests against this value The limits the Gateway enforces are in [Session lifecycle](/gateway/limits-and-rate-limits/#session-lifecycle). Fluxer budgets Identify per source address and caps a user account at a fixed number of concurrent sessions. Neither bound is reported here. :::caution[A remaining session start does not guarantee admission] -A non-zero `remaining` reserves nothing, and neither does the constant `max_concurrency`. [Session admission](/gateway/limits-and-rate-limits/#session-lifecycle) can still hold or reject a connection. +These values reserve no capacity. [Session admission](/gateway/limits-and-rate-limits/#session-lifecycle) can still hold or reject a connection. ::: ## Get Gateway information @@ -82,7 +68,7 @@ A non-zero `remaining` reserves nothing, and neither does the constant `max_conc Returns a [Gateway information object](#gateway-information-object). -The route matches the scheme prefix without regard to case and accepts `Bot `, `Bearer `, or a bare token, then trims the remainder. An empty remainder returns 401 `MISSING_AUTHORIZATION`. A remainder beginning with `flx_` returns 401 `INVALID_AUTH_TOKEN`, because that is the user session token prefix. The route accepts any other remainder only when it has a full stop that is neither its first nor its last character, with decimal digits before it. +Accepted forms are `Bot `, `Bearer ` and ``. Prefixes ignore case and surrounding token whitespace is trimmed. The token must contain a full stop with decimal digits before it and at least one character after it, and must not start with `flx_`. :::caution[Only the token form is enforced] The route checks the shape of the credential only. A well-formed value naming no live application receives the same 200 as a real bot token. @@ -103,9 +89,7 @@ A 200 has the informational [rate limit headers](/topics/rate-limits/#rate-limit ### Side effects -Fluxer records an authentication failure signal of weight 1 against the client IP address for every credential it does not resolve to an account. A bot token resolves only under the `Bot ` prefix, so the bare form and the `Bearer ` form record that signal even on the requests this route answers 200. A client avoids that signal by presenting the `Bot ` prefix. - -A 401 adds a client error signal of weight 0.75, but only when the request resolved no account. A live user session token resolves an account under the `Bearer ` prefix and under no prefix, so the 401 it receives records no signal. Both signals feed the automatic address ban described in [Errors](/http-api/errors/). +Use the `Bot ` authorisation prefix. Repeated invalid authentication can trigger the [abuse protections](/http-api/errors/#client-errors-as-an-abuse-signal). ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/gifs.mdx b/fluxer_docs/src/content/docs/http-api/gifs.mdx index 7099fd5e3..02b0450ba 100644 --- a/fluxer_docs/src/content/docs/http-api/gifs.mdx +++ b/fluxer_docs/src/content/docs/http-api/gifs.mdx @@ -6,7 +6,7 @@ description: Provider-backed GIF search, featured and trending listings, search import RouteHeader from '@/components/RouteHeader.astro'; -A GIF is one animated media item held by the deployment's active GIF provider. Fluxer stores none of them, so every route on this page reads the provider live and returns provider URLs beside signed [Media Proxy](/media-proxy/overview/) URLs. +A GIF is an animated media item from the instance's active provider. Results include provider URLs and [Media Proxy](/media-proxy/overview/) URLs. Every route requires a session credential. A bot token and an OAuth2 bearer credential are both refused with 403 `ACCESS_DENIED`. [Save meme from URL](/http-api/memes/#save-meme-from-url) saves a result into the account's own collection. @@ -36,16 +36,7 @@ Every response under `/gifs`, `/tenor`, and `/klipy` has these headers, includin ## Result freshness -Fluxer caches one answer for each combination of operation, locale, country, and query. Past the refresh bound a request still receives the stored answer, and past the discard bound it waits for a fresh answer. - -| Answer | Refresh bound | Discard bound | -| --- | --- | --- | -| Search results | 30 seconds | 5 minutes | -| Search suggestions | 1 minute | 10 minutes | -| Featured GIFs and trending GIFs | 5 minutes | 30 minutes | -| Featured categories | 24 hours | 48 hours | - -A deployment can configure a further cache in front of those bounds. Neither cache is keyed by the caller, so two accounts sending the same query in the same country receive the same answer. No route on this page invalidates a cached answer. +Results may be cached and can lag behind the provider. Repeating a request does not force a refresh. ## GIF object @@ -252,7 +243,7 @@ Returns at most 50 trending [GIF objects](#gif-object) for the resolved locale a | 403 | [error response](/http-api/#error-response) | The instance has bound no provider key and the request returns `FEATURE_TEMPORARILY_DISABLED` | | 503 | [error response](/http-api/#error-response) | The provider failed, the request outlived its deadline, or the `gifs` service answered with a payload the API could not read, each returning `SERVICE_UNAVAILABLE` | -Trending and featured read the same provider listing, trending for 50 results and featured for one. The two are cached separately, so the single GIF in a [featured](#get-featured-gifs) response can be older than the first entry here. +The [featured](#get-featured-gifs) GIF is not guaranteed to match the first trending result. ### Rate limit @@ -271,7 +262,7 @@ Returns search-term suggestions for a partial query, as an array of strings. | q | string | The partial search term (1-256 characters) | | locale? | string | A [supported locale](/topics/locales/#supported-locales), default `en-US` | -This route sends no country to the provider. Its answers are keyed by locale and query alone, and do not vary with the requesting address. +Suggestions depend on the locale and query, not the requesting address. ### Response @@ -325,7 +316,7 @@ The deadline on this route is 3 seconds. The registration reaches the provider and changes no Fluxer state, so it emits no Gateway Dispatch and does not add the GIF to the account's [memes](/http-api/memes/). -Fluxer neither caches nor deduplicates the call, so registering the same `id` twice reaches the provider twice. +Each request registers a separate share, including repeated requests for the same `id`. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/gifts.mdx b/fluxer_docs/src/content/docs/http-api/gifts.mdx index 9d8efc906..d5a0dcfac 100644 --- a/fluxer_docs/src/content/docs/http-api/gifts.mdx +++ b/fluxer_docs/src/content/docs/http-api/gifts.mdx @@ -30,11 +30,11 @@ Both creation paths record a creator. A completed gift checkout records the purc | redeemed3 | boolean | Whether the code has already been redeemed | | created_by?4 | ?[partial user](/http-api/users/#partial-user-object) object | The account that created the gift | -1 Exactly 32 characters drawn from the uppercase letters, the lowercase letters and the digits, regenerated until it does not collide with an existing code +1 Exactly 32 case-sensitive letters and digits 2 Non-negative, and positive on every gift the current code paths create. The exact value `0` means lifetime Visionary entitlement and appears only on a record that predates that constraint -3 Derived from the stored redemption timestamp. Neither the redemption time nor the redeeming account appears in this object +3 Neither the redemption time nor the redeeming account appears in this object 4 Always present and non-null on this route. A creator ID that resolves to no account becomes a placeholder partial with the unresolved ID, `DeletedUser`, `0000` and `Deleted User` @@ -151,28 +151,13 @@ One redemption can be in flight for a code across the whole deployment, and a se The gift becomes redeemed, so [Get gift](#get-gift) reports `redeemed` as true and [List current user gifts](/http-api/users/gifts/#list-current-user-gifts) shows the redemption time and the redeemer to the buyer. -A gift with a positive quantity extends recurring premium. Fluxer stacks the duration onto the payment provider subscription only when all of these hold: +A positive quantity extends premium from the latest of the current time, the current premium end and the existing gift extension end. An eligible active subscription also has its trial or billing period extended without proration. A provider failure can return 400 `STRIPE_ERROR` and leave the code unredeemed. -- The account's premium type is subscription. -- The account's premium end is unset or in the future. -- The account has a stored subscription identity. -- The instance has a payment provider configured. +A quantity of `0` grants lifetime Visionary premium and immediately cancels an active subscription without proration or a final invoice. It can also assign a Visionary sequence and join the [Visionary guild](/http-api/premium/#rejoin-visionary-guild). -Stacking adds the duration to the existing trial end, or to the current period end when no trial end is set, and applies the update without proration. +A failed Visionary guild join leaves the gift unredeemed. Guild limits return 400 `MAX_GUILDS` or `MAX_GUILD_MEMBERS`. Any subscription cancellation already completed is not reversed. -The stacked and unstacked paths set the gift extension end to the gift duration added to the latest of the current time, the current premium end and the existing gift extension end. Both also clear an active grace deadline and set the premium type when the account has none. The stacked path also records the account as having ever purchased. - -The provider refuses a stacking attempt when the subscription is unknown, already cancelled, or has neither a trial end nor a current period end. That refusal clears the stored subscription identity, the billing cycle and any pending cancellation. The redemption then continues unstacked. Any other provider failure aborts the redemption and returns 400 `STRIPE_ERROR`, and the code stays unredeemed. - -A gift whose quantity is `0` grants lifetime Visionary entitlement. Fluxer then cancels an active subscription immediately, without proration and without a final invoice, and that cancellation also clears the stored subscription identity, the billing cycle and any pending cancellation. The account then receives lifetime premium with no premium end and no grace deadline. - -A lifetime gift that has no Visionary sequence leaves the account's own sequence alone, and an account that already holds one is not joined to the guild. Fluxer allocates a sequence to an account that holds none and joins it to the Visionary guild as described by [Rejoin Visionary guild](/http-api/premium/#rejoin-visionary-guild), including its Gateway Dispatches. - -When a lifetime gift has a Visionary sequence, Fluxer clears the recorded slot of whatever account holds it and joins the redeeming account to the Visionary guild whether or not it already holds a sequence. The gift's sequence overwrites the account's stored sequence, and the slot is reserved for the redeeming account. No current creation path records a sequence on a gift. - -A Visionary guild join that fails aborts the entitlement grant, so the redemption is rolled back and the code stays unredeemed. An account at its guild ceiling returns 400 `MAX_GUILDS` and a Visionary guild at its member ceiling returns 400 `MAX_GUILD_MEMBERS`. A subscription the same request already cancelled is not restored. - -Every entitlement change sends [User Update](/gateway/events/#user-update) to each session owned by the redeemer. Immediate subscription cancellation for a lifetime gift, and the clearing of a refused subscription identity, each produce their own User Update. +Entitlement changes send [User Update](/gateway/events/#user-update) to the redeemer's sessions. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx b/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx index 876f7d80b..aa614b9cc 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-audit-logs.mdx @@ -67,7 +67,7 @@ One recorded guild change. An entry is immutable once written, except for a run 5 The field is omitted when the action records no field change, so it is never returned as an empty array :::note[An entry expires 45 days after the write] -An expired entry is removed from storage, so a page never returns one older than that horizon. A consolidated entry is a fresh write and starts a fresh 45-day expiry of its own. +Entries remain available for 45 days. A consolidated entry starts a new 45-day period. ::: ### Example @@ -245,7 +245,7 @@ Recorded by [GUILD_UPDATE](#audit-actions). 1 The field holds the same value on both sides of every guild action, so it never produces a change object -2 Fluxer sorts the array by string value on both sides of the comparison, so a change object appears only when a feature was added or removed +2 A change object appears only when a feature was added or removed #### Channel change fields @@ -449,7 +449,7 @@ A guild ID that names no guild returns 404 `UNKNOWN_GUILD`. A non-member of an e The read is ordered from newest to oldest by entry ID. `before` selects entries below the cursor and `after` selects entries above it, both read in that same descending order. An `after` page therefore begins with the newest entry above the cursor. -Consolidation breaks that ordering. A consolidated entry keeps the array position of the first entry of the run it replaces. Its ID is newly issued and larger than every stored entry on the page, so a client that needs a strictly descending array sorts the page by ID itself. +Consolidated entries have new IDs but retain the position of the run they replace. Sort the page by ID if a strictly descending array is required. A page holds fewer entries than `limit` only when the guild has no further matching entries in that direction. @@ -464,17 +464,13 @@ A page holds fewer entries than `limit` only when the guild has no further match 1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for a guild with [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features), or [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) without the instance staff flag, and `MISSING_PERMISSIONS` otherwise -:::caution[An unfiltered read rewrites message deletion entries] -A request supplying neither `user_id` nor `action_type` deletes every run of two or more consecutive [MESSAGE_DELETE](#audit-actions) entries sharing one actor and one channel, and writes one [MESSAGE_BULK_DELETE](#audit-actions) entry in its place. -::: - -:::note[The same consolidation also runs in the background] -Fluxer also applies consolidation to the 250 most recent entries about 30 seconds after a message deletion entry is written. +:::caution[An unfiltered read consolidates message deletion entries] +Without `user_id` or `action_type`, runs of two or more consecutive [MESSAGE_DELETE](#audit-actions) entries sharing one actor and one channel become a single [MESSAGE_BULK_DELETE](#audit-actions) entry. ::: ### Side effects -Consolidation writes the replacement entry with a fresh snowflake, `options.count` set to the run length, and no reason. It emits one [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) Dispatch, which the Gateway delivers only to guild sessions holding `VIEW_AUDIT_LOG`. A read that finds no run writes nothing and emits no Dispatch. +A consolidated entry has a new ID, `options.count` equal to the number of replaced entries and no reason. It emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to guild sessions holding `VIEW_AUDIT_LOG`. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guild-channels.mdx b/fluxer_docs/src/content/docs/http-api/guild-channels.mdx index 101899fa1..3d1abbe1a 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-channels.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-channels.mdx @@ -81,7 +81,7 @@ One entry of the bulk hierarchy update. Entries are applied in array order, each 4 The copy runs only when the same entry also moves the channel into a category, and it replaces the moved channel's overwrites -A supplied `position` is clamped to the number of destination siblings and converted into the preceding sibling at that index. An entry that omits both `position` and `preceding_sibling_id` appends a voice channel to the end of its destination siblings, and inserts any other channel directly before the first voice sibling. +A `position` beyond the destination's last sibling places the channel last. Omitting both `position` and `preceding_sibling_id` places a voice channel last and any other channel directly before the first voice sibling. Naming a category as the preceding sibling places the moved channel after that category and after every channel already inside it. @@ -237,14 +237,14 @@ Applies a guild channel hierarchy update and returns 204 with an empty body. Req `MANAGE_CHANNELS` is an [elevated permission](/http-api/permissions/#elevated-permissions), so it also requires an enrolled multi-factor authenticator when the guild [MFA level](/http-api/guilds/#mfa-levels) is elevated and the caller is not the guild owner. -The request takes a guild-wide lock for its duration. A request that arrives while another hierarchy update owns the guild is rejected with 423 [`GENERAL_ERROR`](/http-api/errors/) and a `Retry-After` header of two seconds. The lock expires on its own after 30 seconds. +Concurrent hierarchy updates to the same guild can return 423 [`GENERAL_ERROR`](/http-api/errors/) with a `Retry-After` header of two seconds. -Fluxer resolves every channel, parent, and sibling named by any entry once before the first entry runs, so a request naming an unknown identifier anywhere applies nothing. After that check, each entry is committed as it is applied. A request that fails partway leaves the entries it already applied in place. Moving a category moves the channels inside it as one block, and that block is excluded from the sibling list an entry indexes into. +An unknown channel, parent or sibling rejects the entire request. Other failures can leave earlier entries applied. Moving a category moves its children with it, and neither the category nor its children count as destination siblings. -Each entry flattens the complete guild hierarchy into one list. In that list, each root channel appears in position order, each category is immediately followed by its children, and each category's children put text and link channels before voice channels. Fluxer lifts the moved channel out of that list, together with its children when it is a category, and reinserts it at its destination. It then renumbers the result densely from 1 through the channel count. +After a move, positions run consecutively from 1 across the guild. Each category is followed by its children, with text and link channels before voice channels. -:::note[Renumbering can rewrite a channel no entry named] -An entry that changes the flattened order renumbers the guild's complete hierarchy, so a channel the request never mentioned can end up at a new position. An entry whose flattened result matches the list it started from writes nothing. +:::note[A move can change other channels' positions] +Renumbering can change positions of channels not named in the request. An entry that leaves the order unchanged has no effect. ::: ### Path parameters @@ -258,10 +258,10 @@ An entry that changes the flattened order renumbers the guild's complete hierarc The top-level body is an array of [channel position objects](#channel-position-object) of any length, including zero. Every channel, parent, and sibling an entry names must exist in this guild. A category cannot be given a parent, and a parent that is not a category is refused. A channel cannot be positioned relative to itself or to one of its own children. Moving a channel into a full category is refused with 400 `MAX_CATEGORY_CHANNELS`. :::caution[Inside a category, voice channels come last] -Fluxer checks each entry's planned result before committing it. It refuses the request with `VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS` when a voice channel would come to rest directly above a text or link sibling, or the reverse. +Placing a voice channel above a text or link sibling, or placing a text or link channel below a voice sibling, returns `VOICE_CHANNELS_CANNOT_BE_ABOVE_TEXT_CHANNELS`. ::: -The check inspects the moved channel alone, and only inside its current parent and its destination parent, so no other category can fail an entry. Channels at the guild root are exempt. Every applied entry still orders each category's children with text and link channels first, and a category that already violated the rule is rewritten into that order by the same renumbering. +Channels at the guild root are exempt from this restriction. ### Response @@ -279,11 +279,11 @@ The check inspects the moved channel alone, and only inside its current parent a ### Side effects -The operation changes the position and parent of each affected channel, and every entry that changes the flattened order renumbers the guild's complete hierarchy densely from 1. It emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) per applied entry with the resulting hierarchy filtered to what each recipient can view. It sends nothing for an entry that leaves the order unchanged, and nothing to a recipient whose filtered list is empty. +Each entry that changes the order emits [Channel Update Bulk](/gateway/events/#channel-update-bulk) with the resulting hierarchy filtered to channels each recipient can view. -An entry with `lock_permissions` that changes the parent rewrites the moved channel's overwrites from the destination category after that entry's reorder has already been dispatched. The rewrite has no Gateway Dispatch and no audit entry, so a client must re-read the channel to observe the copied overwrites. +When `lock_permissions` copies the destination category's overwrites, re-read the moved channel to obtain them. The copy has no Gateway event or audit entry. -The rewrite requires more than [MANAGE_ROLES](/http-api/permissions/) in the moved channel. In that channel, the caller must also hold each deny bit the copy would remove and each allow bit it would add. Any bit the caller does not hold returns 403 `MISSING_PERMISSIONS` after the reorder has already been committed. +The caller must hold [MANAGE_ROLES](/http-api/permissions/) in the moved channel and each deny bit the copy removes or allow bit it adds. Insufficient authority returns 403 `MISSING_PERMISSIONS`, but the channel move remains applied. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx b/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx index 72617375d..4cd1c20bc 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-emojis.mdx @@ -12,7 +12,7 @@ Every route names a guild in its path. When that guild has [UNAVAILABLE_FOR_EVER A guild that does not exist returns 404 `UNKNOWN_GUILD`. Fluxer returns 403 `MISSING_PERMISSIONS` to a caller who is not a current member of an existing guild, so guild existence is distinguishable from guild membership. On [Modify guild emoji](#modify-guild-emoji) a non-member receives the same 403 `MISSING_PERMISSIONS`, and a guild that does not exist returns 404 `UNKNOWN_EMOJI`. -The instance phrase and URL blocklists screen a submitted emoji `name` before the operation runs, and a match returns 403 `CONTENT_BLOCKED`. A name of fewer than three characters is not scanned. Neither blocklist reads `image`. Fluxer checks its decoded bytes against the banned asset hash list when it stores them. +Names and uploaded images must pass the instance's content policy. Blocked content returns 403 `CONTENT_BLOCKED`. ## Guild emoji object @@ -112,7 +112,7 @@ One item a bulk creation rejected, identified only by the name the caller submit 2 Rendered in the locale of the authenticated account, so its value changes with the caller's locale -There is no machine-readable code, so a client that needs to branch on the reason retries the item on its own. An item rejected because the guild is full renders the emoji limit message with the resolved limit. Every other failure with a registered [error code](/http-api/errors/) renders that code's own message. An undecodable or unaccepted image renders the generic `INVALID_FORM_BODY` message with no per-field validation code. A banned image hash renders the `CONTENT_BLOCKED` message. A failure with no registered code, such as an object storage fault, renders a fixed unknown-error message. +The failure contains display text, not a machine-readable code. Retry an item through [Create guild emoji](#create-guild-emoji) if a structured error is needed. ## List guild emojis @@ -148,8 +148,6 @@ Fluxer returns the complete collection in one response, and the operation has no Creates one emoji from submitted image data and returns its [guild emoji object](#guild-emoji-object) without `user`. Requires membership of the guild and [CREATE_EXPRESSIONS](/http-api/permissions/). Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event. -Admission checks run in a fixed order: the body schema, then guild existence and membership, then `CREATE_EXPRESSIONS`, then the slot limit, and only then the image. A guild at its slot limit therefore returns `MAX_EMOJIS` even when the image would also have been rejected. - The slot limit is the operator-configured [max_guild_emojis](/http-api/instance/#limit-keys) value resolved against the guild's complete feature set, defaulting to 500. A guild with [UNLIMITED_EMOJI](/http-api/guilds/#guild-features) bypasses that configuration and receives a fixed ceiling of 999999. ### Path parameters @@ -175,11 +173,9 @@ The body is one [emoji create object](#emoji-create-object). 2 The [error code](/http-api/errors/) is `CONTENT_BLOCKED` for a blocked name and for a banned image hash, `MISSING_ACCESS` for an unavailable guild, and `MISSING_PERMISSIONS` for a membership or permission failure -The name scan runs before the route is reached, so it precedes the rate limit bucket and the credential check. Fluxer checks the image hash at upload time, after every other admission step has passed. - ### Side effects -The operation consumes one guild emoji slot and stores the metadata-stripped image. It emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's complete emoji collection to every session connected to the guild, and then writes an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. A failed creation leaves no emoji, audit entry, or Dispatch and consumes no slot. Fluxer logs a failure to write the audit entry and still returns 200, so the emoji exists and the Dispatch is emitted. +Creating an emoji consumes one guild emoji slot, emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the complete collection and records an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. ### Rate limit @@ -224,11 +220,11 @@ The operation returns 200 whenever the top-level request was valid, including wh 2 The [error code](/http-api/errors/) is `CONTENT_BLOCKED` for a blocked item name, `MISSING_ACCESS` for an unavailable guild, and `MISSING_PERMISSIONS` for a membership or permission failure -Every item name is scanned together before the route is reached, so one blocked name rejects the whole request and no item is created. +One blocked name rejects the whole request without creating any emojis. ### Side effects -Each successful item consumes one guild emoji slot, stores its image, and writes an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. When at least one item succeeded, Fluxer emits one [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's complete emoji collection after the batch, then writes the audit entries. A failed item leaves no emoji, audit entry, or slot use. +Each successful item consumes one guild emoji slot and records an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. If any item succeeds, one [Guild Emojis Update](/gateway/events/#guild-emojis-update) contains the guild's complete emoji collection. ### Rate limit @@ -240,11 +236,9 @@ Each successful item consumes one guild emoji slot, stores its image, and writes Copies an existing emoji into the target guild and returns the new [guild emoji object](#guild-emoji-object) without `user`. Requires membership of the target guild and [CREATE_EXPRESSIONS](/http-api/permissions/) there. Emits a [Guild Emojis Update](/gateway/events/#guild-emojis-update) Gateway event in the target guild. -The name, animation state, and stored image bytes are copied server-side, so the caller does not re-upload the image and cannot override any copied value. Membership of the source guild is not required. The source guild must have [CLONE_EMOJI_ENABLED](/http-api/guilds/#guild-features), which a guild holds only after it opts in. +The name, animation state and image are copied unchanged. Membership of the source guild is not required, but it must have opted in with [CLONE_EMOJI_ENABLED](/http-api/guilds/#guild-features). -An unknown source emoji is reported first, then a source guild that is missing or that does not permit cloning, then target membership and permission, and only then the target slot limit. [Get emoji metadata](/http-api/expressions/#get-emoji-metadata) reports whether a source permits cloning without attempting the operation. - -The copied bytes are not re-scanned against the banned asset hash list. +[Get emoji metadata](/http-api/expressions/#get-emoji-metadata) reports whether a source permits cloning. ### Path parameters @@ -277,7 +271,7 @@ A source guild that no longer exists returns 403 `MISSING_ACCESS`, so a caller c ### Side effects -The operation consumes one target guild emoji slot and creates a copy whose uploader is the caller. It emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the target guild's complete emoji collection, then writes an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. The source emoji and source guild are unchanged and receive no Dispatch. +The copy consumes one target guild emoji slot and names the caller as uploader. It emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) in the target guild and records an [`EMOJI_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. The source is unchanged. ### Rate limit @@ -296,7 +290,7 @@ Renames an emoji and returns its [guild emoji object](#guild-emoji-object) witho Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels). -The emoji is resolved within the guild named in the path before any permission is evaluated. An emoji that belongs to another guild and an emoji in a guild that does not exist both return 404 `UNKNOWN_EMOJI`. +An emoji that does not belong to the guild in the path returns 404 `UNKNOWN_EMOJI`. ### Path parameters @@ -343,10 +337,10 @@ Deletes the emoji record and returns 204 with an empty body. Emits a [Guild Emoj - The uploader can delete their own emoji with [CREATE_EXPRESSIONS](/http-api/permissions/). - Any other caller requires [MANAGE_EXPRESSIONS](/http-api/permissions/). -Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels). Fluxer resolves the guild, then the purge eligibility, then the emoji, and only then the permission. A purging request in a guild without the required feature is refused before the emoji is looked up. +Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels). :::caution[A purge cannot be undone through the API] -Without `purge`, the emoji leaves the guild but its image remains available through the [Media Proxy](/media-proxy/routes/#image-asset-contract). With `purge`, the stored object and its `webp` and `gif` CDN representations are queued for permanent deletion. +Without `purge`, the emoji leaves the guild but its image remains available through the [Media Proxy](/media-proxy/routes/#image-asset-contract). With `purge`, the image is scheduled for permanent deletion. ::: :::note[A clone outlives a purge of its source] @@ -382,7 +376,7 @@ A later purge of the source emoji does not affect an existing clone in another g ### Side effects -The operation removes the emoji from the guild, returns its slot, emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the guild's remaining emoji collection, and then writes an [`EMOJI_DELETE`](/http-api/guild-audit-logs/#audit-actions) audit entry. When `purge` is true, the stored object and its cached representations are queued for permanent deletion after the Dispatch and before the audit entry. Existing messages that reference the emoji are not rewritten, and no message Dispatch is produced. +Deletion frees one guild emoji slot, emits [Guild Emojis Update](/gateway/events/#guild-emojis-update) with the remaining collection and records an [`EMOJI_DELETE`](/http-api/guild-audit-logs/#audit-actions) audit entry. Existing messages that reference the emoji are unchanged. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx b/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx index 6f3bac08c..1a89113fe 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-member-search.mdx @@ -1,19 +1,17 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Guild member search -description: Indexed guild member search filters, sorting, pagination, and join metadata. +description: Guild member search filters, sorting, pagination and join metadata. --- import RouteHeader from '@/components/RouteHeader.astro'; -Guild member search finds the members of a guild by name, role, and join information. One route answers it from a search index and returns a [guild member search result](#guild-member-search-result-object) object for each match. +Search guild members by name, role and join information. Each match is a [guild member search result](#guild-member-search-result-object) object. A guild an operator has marked unavailable returns 403 `MISSING_ACCESS` before the route runs. -Results are eventually consistent with direct membership reads. Fluxer logs a failed index write and discards it. - -:::caution[A dropped removal is never repaired] -A rebuild re-upserts every current membership and repairs a dropped create or update. It deletes no document, so an already indexed guild keeps returning a member who has left. +:::caution[Search results can be stale] +Results can lag membership changes and can include members who have left. Use [Get guild member](/http-api/guild-members/#get-guild-member) when current membership matters. ::: ## Join source types @@ -33,13 +31,13 @@ The value 5 is not assigned. ## Guild member search result object -A guild member search result is one matched membership as the search index stored it. It flattens the account fields the [guild member object](/http-api/guild-members/#guild-member-object) nests under `user`. +A matched membership. Account fields appear at the top level rather than under `user` as in the [guild member object](/http-api/guild-members/#guild-member-object). ### Structure | Field | Type | Description | | --- | --- | --- | -| id1 | string | The identity of the index document, one per membership | +| id1 | string | The search result identifier, one per membership | | guild_id | snowflake | The ID of the guild the membership belongs to | | user_id | snowflake | The ID of the account the membership belongs to | | username | string | The username of the account | @@ -198,7 +196,7 @@ With no `sort_by`, the route sorts by join time and defaults `sort_order` to `de Text matching runs over the username, the discriminator, the global display name, the guild nickname, and the user ID. A query can match the middle of a username as well as its start. The search backend defines term matching and typo tolerance. Filter matching is exact in every case. :::caution[A deep `offset` returns 200 with an empty page] -Elasticsearch walks past its 10000 document window with an internal cursor and serves an arbitrary `offset`. Meilisearch caps the window at 10000 and returns an empty page beyond it. +An instance using Meilisearch returns an empty page beyond 10000 results. Instances using Elasticsearch support larger offsets. ::: ### Response @@ -212,15 +210,15 @@ Elasticsearch walks past its 10000 document window with an internal cursor and s 1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `CONTENT_BLOCKED` for a blocked body string, and `MISSING_PERMISSIONS` otherwise -An index that has never been built, or that predates the instance forced reindex point, returns 200 with an empty page and `indexing` set to true, and the request schedules the rebuild. The same empty body with `indexing` false covers a guild whose record disappeared between the membership check and the index read, an instance with no search backend configured, and an instance still starting up. This operation never returns `FEATURE_TEMPORARILY_DISABLED`. +When results are being prepared, the response is 200 with an empty page and `indexing` set to true. Retry later. Search unavailability can also produce an empty page with `indexing` false, rather than `FEATURE_TEMPORARILY_DISABLED`. :::note[`indexing` false does not mean zero matches] -True means the rebuild is scheduled and the page is empty for that reason alone. False covers a genuine zero-match search and every other index that could not answer. +An empty page with `indexing` false can mean no matches or that search is unavailable. ::: ### Side effects -The route queues a rebuild of the guild's member index when the guild has never been indexed or was last indexed before the instance forced reindex point. +The first search can begin preparing the guild's results. It does not change memberships. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guild-members.mdx b/fluxer_docs/src/content/docs/http-api/guild-members.mdx index a75e46b36..3857bb039 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-members.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-members.mdx @@ -8,11 +8,11 @@ import RouteHeader from '@/components/RouteHeader.astro'; A guild member is an account that has joined a guild. The membership has a nickname, avatar, role set, and moderation state that apply in that guild alone. Indexed member queries live on [Guild member search](/http-api/guild-member-search/), and bans on [Guild moderation](/http-api/guild-moderation/). -A guild that no Gateway process serves returns 404 `UNKNOWN_GUILD`. An existing guild where the caller is not a member returns 403 `MISSING_PERMISSIONS`. Fluxer refuses a guild an operator has marked unavailable with 403 `MISSING_ACCESS` before the route runs. [Transfer guild ownership](#transfer-guild-ownership) is the one exception to the 404, and returns 403 `ACCESS_DENIED` for a guild whose record survives without a Gateway process. +An unknown guild returns 404 `UNKNOWN_GUILD`, a non-member receives 403 `MISSING_PERMISSIONS`, and a guild marked unavailable returns 403 `MISSING_ACCESS`. [Transfer guild ownership](#transfer-guild-ownership) can also return 403 `ACCESS_DENIED` when the guild cannot be accessed. The modify operations return the resulting membership, [Transfer guild ownership](#transfer-guild-ownership) returns the updated guild, and every other mutation returns 204 with an empty body. -:::note[Fluxer reads the target before authorising the caller] +:::note[A missing target returns 404] A request whose target is not a member returns 404 `UNKNOWN_MEMBER` whether or not the caller can see the guild. The rule covers the modify operations, [Remove guild member](#remove-guild-member), [Add guild member role](#add-guild-member-role), and [Remove guild member role](#remove-guild-member-role). ::: @@ -49,7 +49,7 @@ Every field except `user` describes state that belongs to the membership. 6 Omitted while the stored value is 0, so absence means no flag is set and, for `mention_flags`, that the account-level preference applies -Premium sanitisation hides the stored avatar and banner hashes and keeps them on the membership. Fluxer applies it after the account loses its premium entitlement, to each membership that holds a guild avatar, banner, biography, or accent colour. +Guild avatars and banners can be hidden after the account loses its premium entitlement. A client compares `communication_disabled_until` against the current time, because a non-null value can name a moment that has already passed. A [communication timeout](/http-api/permissions/#communication-timeout) does not reduce the member's computed permission mask. @@ -202,8 +202,6 @@ Returns the authenticated account's own [guild member object](#guild-member-obje Returns one member's [guild member object](#guild-member-object). Requires membership of the guild and no permission. -This route resolves the guild for the caller before it reads the target, so a non-member never learns whether the target is one. - ### Path parameters | Field | Type | Description | @@ -238,7 +236,7 @@ Modifies the authenticated account's own membership and returns the resulting [g A caller without `CHANGE_NICKNAME` does not fail the request. Fluxer discards the nickname change and applies every other supplied field. A member who holds the permission but is timed out is rejected with 403 `COMMUNICATION_DISABLED`. -A supplied `nick`, `bio`, or `pronouns` is scanned against the instance phrase, URL, and profile substring blocklists, and a match returns 403 `CONTENT_BLOCKED`. +A blocked `nick`, `bio` or `pronouns` returns 403 `CONTENT_BLOCKED`. Guild avatar, banner, biography, and accent colour also require the instance-configured `feature_per_guild_profiles` [limit](/http-api/instance/#limit-keys) for the calling account. Fluxer discards any of those fields supplied without that capability and applies every other field. Pronouns, `profile_flags`, and `mention_flags` do not require it. @@ -277,7 +275,7 @@ The body is a [guild member update object](#guild-member-update-object) without ### Side effects -Fluxer updates the membership, replaces any supplied guild avatar or banner, updates member search results, records a [`MEMBER_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry, and emits [Guild Member Update](/gateway/events/#guild-member-update). +The operation updates the membership, records a [`MEMBER_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry and emits [Guild Member Update](/gateway/events/#guild-member-update). The target's own sessions always receive the Dispatch. Other guild sessions receive it subject to [event filtering](/gateway/event-filtering/). The audit entry and Dispatch are produced even when the resulting membership is unchanged, and an entry whose change list is empty is published without a `changes` member. @@ -303,7 +301,7 @@ Modifies another member's guild state and returns the resulting [guild member ob - Applying `mute` requires [MUTE_MEMBERS](/http-api/permissions/), applying `deaf` requires [DEAFEN_MEMBERS](/http-api/permissions/), and both require hierarchy authority over the target. - Moving the target requires [MOVE_MEMBERS](/http-api/permissions/) and hierarchy authority, plus [VIEW_CHANNEL](/http-api/permissions/) and [CONNECT](/http-api/permissions/) for the caller in the destination. -A supplied `nick` is scanned against the instance phrase, URL, and profile substring blocklists, and a match returns 403 `CONTENT_BLOCKED`. +A blocked `nick` returns 403 `CONTENT_BLOCKED`. Hierarchy authority over the target is not checked for `roles`, so a caller who outranks every affected role can replace the role set of a member who outranks them. The everyone role in the array is rejected with the field code `INVALID_ROLE_ID`. @@ -352,7 +350,7 @@ The body is a [guild member update object](#guild-member-update-object). ### Side effects -The operation has the same membership, audit, search, [Guild Member Update](/gateway/events/#guild-member-update), and [Voice State Update](/gateway/events/#voice-state-update) effects as [Modify current guild member](#modify-current-guild-member). Applying a non-empty replacement role set to a temporary membership also makes the membership permanent. The target account always receives its own Guild Member Update, while every other guild session receives it subject to [event filtering](/gateway/event-filtering/). +The operation has the same effects as [Modify current guild member](#modify-current-guild-member). Applying a non-empty role set also makes a temporary membership permanent. ### Rate limit @@ -390,9 +388,9 @@ The caller and the guild owner cannot be removed through this operation, and bot ### Side effects -The operation retains the member's first join time, leave time, and any stored communication timeout for one year. Rejoining during that period resumes a timeout that is still in the future. +Rejoining within one year resumes any communication timeout that has not expired. -It deletes the membership row and decreases the guild's member count, so the guild nickname, guild avatar hash, guild banner hash, biography, pronouns, accent colour, and role set are lost. The removed account's read states and guild settings are left untouched, and the guild is not removed from that account's guild folders. +The member loses their guild profile and roles. Read states, guild settings and guild folders are unchanged. Fluxer records a [`MEMBER_KICK`](/http-api/guild-audit-logs/#audit-actions) audit entry with no change list and emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). Remaining guild sessions receive [Guild Member Remove](/gateway/events/#guild-member-remove), subject to [event filtering](/gateway/event-filtering/). The removed account's sessions receive [Guild Delete](/gateway/events/#guild-delete). @@ -412,7 +410,7 @@ Transfers guild ownership to another current member and returns the updated [gui - The target must be a current member. - A bot target returns 400 `CANNOT_TRANSFER_OWNERSHIP_TO_BOT`. -The operation requires sudo mode as described by the [sudo verification fields](/http-api/guilds/#sudo-verification-fields), which a bot credential satisfies without a proof. Fluxer verifies sudo mode before ownership, so a member who is not the owner must still prove sudo mode before receiving 403 `MISSING_PERMISSIONS`. An account that supplies no usable proof receives 403 `SUDO_MODE_REQUIRED`, whose payload names the second factors the account holds. +The operation requires the [sudo verification fields](/http-api/guilds/#sudo-verification-fields). A bot credential needs no proof. An account without a usable proof receives 403 `SUDO_MODE_REQUIRED`, whose payload names its available second factors. ### Path parameters @@ -497,7 +495,7 @@ The guild owner receives 404 `UNKNOWN_ROLE` for a role that does not exist in th ### Side effects -When the member does not already hold the role, the operation adds it and makes a temporary membership permanent. It then updates member search results, records a [`MEMBER_ROLE_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry, emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Member Update](/gateway/events/#guild-member-update). These all run whether or not the role set changed. A request naming a role the member already holds still writes an audit entry with an empty change list, published without a `changes` member. +Adding a new role makes a temporary membership permanent. The operation records a [`MEMBER_ROLE_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry and emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) and [Guild Member Update](/gateway/events/#guild-member-update), even if the member already holds the role. An unchanged role set produces an audit entry without `changes`. The audit log response names the role only through the change list. @@ -541,7 +539,7 @@ Removes one role from a member and returns 204 with an empty body. Requires memb ### Side effects -When the member holds the role, the operation removes it. It then updates member search results, records a [`MEMBER_ROLE_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry, emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Member Update](/gateway/events/#guild-member-update). These all run whether or not the role set changed. Removing a role does not change whether the membership is temporary. +The operation records a [`MEMBER_ROLE_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry and emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) and [Guild Member Update](/gateway/events/#guild-member-update), even if the member did not hold the role. Removing a role does not change whether the membership is temporary. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx b/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx index 6481d4693..ab13808f8 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-moderation.mdx @@ -45,14 +45,10 @@ A guild ban object names the banned account, the moderator, the reason, and any ``` :::note[Fluxer never returns the ban address or email] -A ban also stores the target's last known IP address and the lower-cased address of their account email. An address on the instance exemption list is stored as null. +A ban can also prevent accounts using the same IP address or email from joining. These values are not exposed in the ban object. ::: -Accepting an [invite](/http-api/invites/) compares the joining account against the banned account identifier, the stored address, and the stored email. The address comparison uses a normalised decision key: the exact address for IPv4, the mapped IPv4 address for an IPv4-mapped IPv6 address, and the `/64` network for every other IPv6 address. An address match refuses the join with 403 `USER_IP_BANNED_FROM_GUILD`. - -Fluxer skips the address match when the joining address is on the instance exemption list. It also skips the match when the banned address is a single address and the joining address is reported as high mobile or carrier-grade NAT blast radius. The match runs when that report is unavailable or fails. - -The email comparison is exact and runs only when an invite is accepted. An email match refuses the join with 403 `USER_BANNED_FROM_GUILD`. Every other path that adds a member compares the account identifier and the address alone, and several compare nothing. [Grant OAuth2 consent](/http-api/oauth2/#grant-oauth2-consent) installing a bot, the admin membership operations, the stock community auto-join, and the premium entitlement guild join all add the member without checking the ban list. +An [invite](/http-api/invites/) rejected for an IP match returns 403 `USER_IP_BANNED_FROM_GUILD`. An account or email match returns 403 `USER_BANNED_FROM_GUILD`. Address exemptions can allow a join from a shared network. Ban checks differ for administrator actions, bot installation and automatic guild joins. [Remove guild ban](#remove-guild-ban) releases both blocks. Permanently deleting the banned account deletes every guild ban it holds, and that releases both blocks in every guild at once. @@ -101,7 +97,7 @@ Creates a guild ban, or replaces an existing one, and returns 204 with an empty - Banning a target who is a member also requires role hierarchy authority over that member. The guild owner holds that authority over everyone, and no other caller holds it over the owner. - A target who is not a member can still be banned, and the ban pre-empts a future join. -Fluxer scans every string in a JSON request body against the instance phrase and URL blocklists, and that includes `reason`. A blocked value returns 403 `CONTENT_BLOCKED` before the rate limit bucket, the credential check, and the permission check. +A blocked `reason` returns 403 `CONTENT_BLOCKED`. ### Path parameters @@ -134,7 +130,7 @@ The body is optional, and so is every field in it. Fluxer reads an omitted or wh An omitted or null `reason` falls back to the `X-Audit-Log-Reason` value, while a supplied value that normalises to the empty string is stored as null without falling back. The header remains the audit entry reason in every case. :::caution[Banning an already banned account succeeds] -The second ban overwrites the stored reason, moderator, issue time, expiry, address, and email. Re-issuing can downgrade a permanent ban to a temporary one or extend a temporary one. There is no conditional form. +The second ban replaces the previous ban's details. Re-issuing can turn a permanent ban into a temporary one or extend a temporary ban. There is no conditional form. ::: ### Response @@ -155,9 +151,9 @@ The second ban overwrites the stored reason, moderator, issue time, expiry, addr ### Side effects -The operation stores the ban together with the target's last known IP address and account email. It records a [`MEMBER_BAN_ADD`](/http-api/guild-audit-logs/#audit-actions) audit entry whose change list has the stored [guild ban change fields](/http-api/guild-audit-logs/#guild-ban-change-fields), emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create), and emits [Guild Ban Add](/gateway/events/#guild-ban-add) to the guild's sessions, subject to [event filtering](/gateway/event-filtering/). A positive deletion interval schedules permanent deletion of the target's newer guild messages. +The operation records a [`MEMBER_BAN_ADD`](/http-api/guild-audit-logs/#audit-actions) audit entry with the [guild ban change fields](/http-api/guild-audit-logs/#guild-ban-change-fields). It emits [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) and [Guild Ban Add](/gateway/events/#guild-ban-add), subject to [event filtering](/gateway/event-filtering/). A positive deletion interval permanently deletes the target's guild messages within that interval. -A target that is a current member also loses that membership. The guild member count decreases. The guild's sessions receive [Guild Member Remove](/gateway/events/#guild-member-remove), the banned account's sessions receive [Guild Delete](/gateway/events/#guild-delete), and the membership is dropped from the member search index of an indexed guild. Fluxer records no `MEMBER_KICK` entry alongside the ban entry. +A current member is removed. The guild receives [Guild Member Remove](/gateway/events/#guild-member-remove) and the banned account receives [Guild Delete](/gateway/events/#guild-delete). No `MEMBER_KICK` audit entry is recorded. A ban does not preserve an active communication timeout for a later rejoin, and [Remove guild member](/http-api/guild-members/#remove-guild-member) does. The banned account loses its guild nickname, guild avatar hash, guild banner hash, biography, pronouns, accent colour, and role set. Its read states and guild settings stay in place. diff --git a/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx b/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx index b6044e2e4..31c704e56 100644 --- a/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx +++ b/fluxer_docs/src/content/docs/http-api/guild-stickers.mdx @@ -10,7 +10,7 @@ A sticker is an image a guild stores for its members to send with a message. It Every route names a guild in its path. A guild that has [UNAVAILABLE_FOR_EVERYONE](/http-api/guilds/#guild-features) rejects the request with 403 `MISSING_ACCESS` before the operation runs. [UNAVAILABLE_FOR_EVERYONE_BUT_STAFF](/http-api/guilds/#guild-features) does the same for an account without the instance staff flag. On [Clone guild sticker](#clone-guild-sticker) the gate covers the target guild and never the source guild. -The instance-wide content filter screens every route with a JSON body before the route runs. A body string of at least 3 characters matching the phrase blocklist, or a URL matching the URL blocklist, returns 403 `CONTENT_BLOCKED`. The `image` member is exempt. +Names, descriptions, tags and uploaded images must pass the instance's content policy. Blocked content returns 403 `CONTENT_BLOCKED`. A guild that does not exist returns 404 `UNKNOWN_GUILD`. A caller who is not a current member of an existing guild returns 403 `MISSING_PERMISSIONS`, so guild existence is distinguishable from guild membership. [Modify guild sticker](#modify-guild-sticker) answers 404 `UNKNOWN_STICKER` for such a guild. @@ -18,7 +18,7 @@ There is no single-sticker read scoped to a guild. [List guild stickers](#list-g ## Guild sticker object -A guild sticker object is the complete stored form of one sticker. The stored image is written once at creation and is never replaced. Only the name, description, and tags are mutable. +A guild sticker object describes one sticker. Only its name, description and tags can change. Replacing the image requires creating a new sticker. ### Structure @@ -168,8 +168,6 @@ One response has the complete collection. The operation is not paginated. A stic Creates one sticker from submitted image data and returns its [guild sticker object](#guild-sticker-object) without `user`. Requires membership of the guild and [CREATE_EXPRESSIONS](/http-api/permissions/). Emits a [Guild Stickers Update](/gateway/events/#guild-stickers-update) Gateway event. -Admission checks run in a fixed order. Fluxer scans the submitted name, description, and tags for prohibited content, then resolves guild existence and membership, then `CREATE_EXPRESSIONS`, then the slot limit, and only then the image. A guild at its slot limit therefore returns `MAX_STICKERS` even when the image would also have been rejected. - The slot limit is the operator-configured [max_guild_stickers](/http-api/instance/#limit-keys) value resolved against the guild's complete feature set, defaulting to 500. A guild with [UNLIMITED_STICKERS](/http-api/guilds/#guild-features) bypasses that configuration and receives a fixed ceiling of 999999. ### Path parameters @@ -197,7 +195,7 @@ The body is one [sticker create object](#sticker-create-object). ### Side effects -The operation consumes one guild sticker slot, stores the metadata-stripped image, emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the guild's complete sticker collection, and then writes a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. A failed creation leaves no sticker, audit entry, or Dispatch and consumes no slot. +Creating a sticker consumes one guild sticker slot, emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the complete collection and records a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. ### Rate limit @@ -242,13 +240,13 @@ The operation returns 200 whenever the top-level request was valid, including wh A decoded image that is oversized, undecodable, in an unaccepted format, or matching a banned file hash fails one item. -:::note[The blocklist screens run with different floors] -The global body screen runs before the batch starts and ignores strings shorter than 3 characters, so a blocked string there fails the whole request. The per-item scan applies the same blocklist with no floor, and only a shorter match becomes an item failure. +:::note[Blocked text can reject the whole batch] +A blocked name, description or tag of at least 3 characters rejects the whole request. A shorter blocked value fails only that item. ::: ### Side effects -Each successful item consumes one guild sticker slot, stores its image, and writes the supplied audit reason into its [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) entry. A failed item leaves no sticker or audit entry and consumes no slot. +Each successful item consumes one guild sticker slot and records a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the supplied reason. Fluxer emits one [Guild Stickers Update](/gateway/events/#guild-stickers-update) after the batch when at least one item succeeded, with the guild's complete sticker collection. A batch in which no item succeeded emits nothing. @@ -264,9 +262,7 @@ Copies an existing sticker into the target guild and returns the new [guild stic The caller needs membership of the target guild and [CREATE_EXPRESSIONS](/http-api/permissions/) there. Membership of the source guild is not required. The source guild must have [CLONE_STICKER_ENABLED](/http-api/guilds/#guild-features), which a guild holds only after it opts in. -The name, description, tags, animation state, and stored image bytes are copied server-side, so the caller does not re-upload the image and cannot override any copied value. - -The availability gate on the target guild runs first. An unknown source sticker is then reported before a missing source guild, before target membership and permission, and before the target slot limit. A request naming an unknown source sticker against a target guild that does not exist returns 404 `UNKNOWN_STICKER`. +The name, description, tags, animation state and image are copied unchanged. [Get sticker metadata](/http-api/expressions/#get-sticker-metadata) reports whether a source permits cloning without attempting the operation. @@ -301,7 +297,7 @@ A source guild that no longer exists returns 403 `MISSING_ACCESS`, so a caller c ### Side effects -The operation consumes one target guild sticker slot and creates a copy whose uploader is the caller. It emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the target guild's complete sticker collection and then writes a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. The source sticker and source guild are unchanged and receive no Dispatch. +The copy consumes one target guild sticker slot and names the caller as uploader. It emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) in the target guild and records a [`STICKER_CREATE`](/http-api/guild-audit-logs/#audit-actions) audit entry. The source is unchanged. ### Rate limit @@ -319,7 +315,7 @@ Replaces sticker metadata and returns the [guild sticker object](#guild-sticker- - Neither permission is subject to the guild [MFA level](/http-api/guilds/#mfa-levels). - The stored image cannot be replaced, so an image change requires creating a new sticker. -The sticker is resolved before any permission is evaluated. A sticker that belongs to another guild and a sticker in a guild that does not exist are both reported as an unknown sticker. +A sticker that does not belong to the guild in the path returns 404 `UNKNOWN_STICKER`. :::caution[Every request writes `description` and `tags`] Only `name` is required. Omitting `description` clears the stored description and omitting `tags` clears the stored tags. A client changing one field submits the current value of the other two alongside it. @@ -375,10 +371,8 @@ Deletes the sticker record and returns 204 with an empty body. Emits a [Guild St - The uploader can delete their own sticker with [CREATE_EXPRESSIONS](/http-api/permissions/), and any other caller requires [MANAGE_EXPRESSIONS](/http-api/permissions/). - Setting `purge` needs the guild to have [EXPRESSION_PURGE_ALLOWED](/http-api/guilds/#guild-features). -Fluxer checks membership, then purge eligibility, then the sticker, and only then the permission. A non-member receives `MISSING_PERMISSIONS` whatever `purge` is set to, and a purging request in a guild without the required feature is refused before the sticker is looked up. - :::caution[Purging the stored image cannot be undone] -Without `purge`, the sticker leaves the guild but its image remains available through the [Media Proxy](/media-proxy/routes/#image-asset-contract). With `purge`, the stored image and its `png`, `jpeg`, `apng`, `gif`, `webp`, `avif`, and `svg` CDN representations are queued for permanent deletion. +Without `purge`, the sticker leaves the guild but its image remains available through the [Media Proxy](/media-proxy/routes/#image-asset-contract). With `purge`, the image is scheduled for permanent deletion. ::: :::note[A clone outlives a purge of its source] @@ -416,7 +410,7 @@ A later purge of the source sticker does not affect an existing clone in another The operation removes the sticker from the guild, returns its slot, emits [Guild Stickers Update](/gateway/events/#guild-stickers-update) with the guild's remaining sticker collection, and then writes a [`STICKER_DELETE`](/http-api/guild-audit-logs/#audit-actions) audit entry. -When `purge` is true, the stored image and each cached representation are deleted after the response, so the image can still be served for a short time after the 204. Messages that reference the sticker are not rewritten. No message Dispatch is produced. +With `purge`, the image can remain available briefly after the 204. Existing messages that reference the sticker are unchanged. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/guilds.mdx b/fluxer_docs/src/content/docs/http-api/guilds.mdx index d55e942c1..bb183322f 100644 --- a/fluxer_docs/src/content/docs/http-api/guilds.mdx +++ b/fluxer_docs/src/content/docs/http-api/guilds.mdx @@ -10,11 +10,11 @@ A guild is a community with its own channels, roles, members, and configuration. [List current user guilds](#list-current-user-guilds) and [Get guild](#get-guild) declare the `guilds` [OAuth2 scope](/http-api/oauth2/#oauth2-scopes), and a bearer credential without that scope receives 403 `MISSING_OAUTH_SCOPE`. Every other route rejects a bearer credential with 403 `ACCESS_DENIED`. -A guild that does not exist returns 404 `UNKNOWN_GUILD`. An existing guild that does not hold the caller as a member returns 403 `MISSING_PERMISSIONS`. So any authenticated caller can tell a missing guild from one they are not in. A guild whose stored record exists while the main Gateway reports it unknown returns 403 `ACCESS_DENIED`. +A guild that does not exist returns 404 `UNKNOWN_GUILD`. A non-member receives 403 `MISSING_PERMISSIONS`. A guild that exists but cannot be accessed can return 403 `ACCESS_DENIED`. -Fluxer checks [guild availability](#guild-features) on a route whose path opens with `/guilds/{guild_id}` before the operation runs. [Create guild](#create-guild), [List current user guilds](#list-current-user-guilds), [Leave guild](#leave-guild), and [Bulk delete current user's guild messages](#bulk-delete-current-users-guild-messages) do not have the guild in their first two path segments, so a forced-unavailable guild is still listed and can still be left. +An [unavailable guild](#guild-features) can still appear in [List current user guilds](#list-current-user-guilds). Members can still [leave](#leave-guild) or [delete their own messages](#bulk-delete-current-users-guild-messages). -The instance-wide content filter screens the JSON body of every `POST`, `PUT`, and `PATCH` route here before the route runs. A string of at least 3 characters matching the phrase blocklist, or a URL matching the URL blocklist, returns 403 `CONTENT_BLOCKED`. The `icon`, `banner`, `splash`, `embed_splash`, `permissions`, `roles`, `channels`, `password`, `mfa_code`, `webauthn_response`, and `webauthn_challenge` members are exempt. The guild `name` is scanned a second time inside [Create guild](#create-guild) and [Modify guild](#modify-guild), without the 3 character floor. +Submitted names and other text must pass the instance's content policy. Blocked content returns 403 `CONTENT_BLOCKED`. ## Guild object @@ -62,8 +62,8 @@ A guild object contains the guild's configuration. The operation that returns it | channels?13 | array[[channel](/http-api/channels/#channel-object) object] | Guild channels the caller can view | | member_count?14 | integer | Member count held by the main Gateway | | online_count?14 | integer | Online presence count held by the main Gateway | -| approximate_member_count?15 | integer | Member count read from the main Gateway count cache | -| approximate_presence_count?15 | integer | Online presence count read from the main Gateway count cache | +| approximate_member_count?15 | integer | Approximate member count | +| approximate_presence_count?15 | integer | Approximate online presence count | 1 An animated icon hash has the `a_` prefix, and that prefix is removed from the returned value while the guild lacks `ANIMATED_ICON` @@ -91,7 +91,7 @@ A guild object contains the guild's configuration. The operation that returns it 13 Only [Get guild](#get-guild) populates this field, and the array is filtered to the channels the caller can currently view -14 Only [Get guild](#get-guild) populates these fields, and both are computed from the guild's live main Gateway state +14 Only [Get guild](#get-guild) populates these fields 15 Only [List current user guilds](#list-current-user-guilds) populates these fields, and only when `with_counts` is true. A guild whose counts are not currently held reports 0 for both @@ -416,7 +416,7 @@ Each value in the guild's `features` array is a capability or availability flag. 3 The expression operations raise the slot ceiling directly, outside the instance limit configuration -4 An authenticated request whose path opens with `/guilds/{guild_id}` or `/channels/{channel_id}`, where that channel belongs to this guild, is rejected before the operation runs with 403 `MISSING_ACCESS`. `UNAVAILABLE_FOR_EVERYONE` applies to the guild owner exactly as it applies to every other member, while `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` exempts an account with the instance staff flag +4 Guild and channel routes return 403 `MISSING_ACCESS`. `UNAVAILABLE_FOR_EVERYONE` includes the guild owner. `UNAVAILABLE_FOR_EVERYONE_BUT_STAFF` exempts accounts with the instance staff flag 5 The feature raises the default `max_guild_members` limit from 1000000 to 10000000 before the ordered [limit configuration](/http-api/instance/#limit-keys) is checked, so the raised ceiling applies even when no configured rule names the feature @@ -425,7 +425,7 @@ Each value in the guild's `features` array is a capability or availability flag. 7 The feature is deprecated and changes no behaviour. Fluxer still returns it for a guild that already holds it, and [Modify guild](#modify-guild) can neither add nor remove it :::caution[Expression cloning is opt-in] -`CLONE_EMOJI_ENABLED` and `CLONE_STICKER_ENABLED` replace the deprecated `CLONE_EMOJI_DISABLED` and `CLONE_STICKER_DISABLED`, and they are now the only features that permit cloning. A guild that holds neither refuses every clone of its emojis and stickers, so cloning is off until a member with `MANAGE_GUILD` adds the feature through [Modify guild](#modify-guild). A guild that once held a deprecated feature and a guild that never held one both start off. +Cloning requires `CLONE_EMOJI_ENABLED` or `CLONE_STICKER_ENABLED`. A member with `MANAGE_GUILD` can enable them through [Modify guild](#modify-guild). The deprecated `CLONE_EMOJI_DISABLED` and `CLONE_STICKER_DISABLED` features have no effect. ::: ## Custom invite URL object @@ -478,7 +478,7 @@ These fields prove [sudo mode](/http-api/users/mfa/#sudo-mode) when an operation 2 The MFA proof is accepted only when the account has a second factor, and it returns the field code `INVALID_MFA_CODE` on any failure -A bot credential satisfies sudo mode without any proof, and so does an account that has no password hash and no second factor. Every other account that supplies no usable proof receives 403 `SUDO_MODE_REQUIRED`, whose error object has `has_mfa` and a `methods` object reporting whether `totp` and `webauthn` are available. +A bot credential satisfies sudo mode without any proof, and so does an account with no password or second factor. Every other account that supplies no usable proof receives 403 `SUDO_MODE_REQUIRED`, whose error object has `has_mfa` and a `methods` object reporting whether `totp` and `webauthn` are available. Fluxer issues a newly generated proof in the `X-Fluxer-Sudo-Mode-JWT` header of the success response, and only for an account that has a second factor. A proof supplied on the request is echoed back in that same header. @@ -496,8 +496,6 @@ Creates a guild owned by the caller. Requires a user session credential. Returns - A caller already at the configured guild limit is rejected with 400 `MAX_GUILDS`. - While the instance's single community policy is active, every caller is rejected with 400 `SINGLE_COMMUNITY_CANNOT_CREATE_GUILDS`. -Fluxer decides the single community refusal before the email, bot, unclaimed, and guild limit failures. - ### JSON body | Field | Type | Description | @@ -513,7 +511,7 @@ Fluxer decides the single community refusal before the email, bot, unclaimed, an 3 When false the guild is created holding `ANIMATED_ICON`, `ANIMATED_BANNER`, `BANNER`, and `INVITE_SPLASH` -A `name` that satisfies the 1 to 100 character bound only before normalisation is rejected with the field code `STRING_LENGTH_INVALID`. The normalised name is scanned against the instance phrase and URL blocklists, and a match returns 403 `CONTENT_BLOCKED`. +A `name` outside 1 to 100 characters after normalisation returns `STRING_LENGTH_INVALID`. A blocked name returns 403 `CONTENT_BLOCKED`. The `icon` base64 payload is bounded to 1 to 13981016 characters and is otherwise rejected with `BASE64_LENGTH_INVALID`, and a payload that is not valid base64 with `INVALID_BASE64_FORMAT`. The decoded image must fit within the instance `avatar_max_size` limit, which defaults to the 10 MiB ceiling, and a larger image is rejected with `IMAGE_SIZE_EXCEEDS_LIMIT`. PNG, JPEG, WebP, GIF, APNG, AVIF, HEIC, HEIF, JXL, and SVG are accepted. An animated AVIF is rejected with `INVALID_IMAGE_FORMAT`. @@ -562,7 +560,7 @@ Returns an array of [guild objects](#guild-object), one for every guild the auth 2 A guild whose counts cannot be fetched is returned without them -Fluxer drops a membership whose guild record can no longer be resolved before it applies the cursor and `limit`, so the page still has up to `limit` guilds when further guilds remain. This route runs no availability check, and a guild with `UNAVAILABLE_FOR_EVERYONE` is still listed. +The page excludes deleted guilds but includes guilds marked `UNAVAILABLE_FOR_EVERYONE`. :::caution[`permissions` needs a limit of 100 or lower] A page of more than 100 guilds, or a failed lookup, omits `permissions` from every guild. The request still succeeds. @@ -603,7 +601,7 @@ The response has no `permissions` field. Read [List current user guilds](#list-c | 4031 | [error response](/http-api/#error-response) | Guild is unavailable, the bearer credential lacks the `guilds` scope, or the caller is not a member | | 404 | [error response](/http-api/#error-response) | Guild does not exist and the request returns `UNKNOWN_GUILD` | -1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_OAUTH_SCOPE` for a missing scope, `MISSING_PERMISSIONS` for a non-member, and `ACCESS_DENIED` when the record exists while the Gateway reports the guild unknown +1 The [error code](/http-api/errors/) is `MISSING_ACCESS` for an unavailable guild, `MISSING_OAUTH_SCOPE` for a missing scope, `MISSING_PERMISSIONS` for a non-member, and `ACCESS_DENIED` when the guild otherwise cannot be accessed ### Rate limit @@ -663,7 +661,7 @@ Every field is optional. An omitted field preserves its current value, and a fie | webauthn_response? | [WebAuthn assertion](/http-api/authentication/#webauthn-assertion) object | [Sudo verification](#sudo-verification-fields) assertion | | webauthn_challenge? | string | Challenge bound to the WebAuthn assertion | -1 The value is normalised and trimmed before its length is measured, and the normalised name is scanned against the instance phrase and URL blocklists, so a match returns 403 `CONTENT_BLOCKED` +1 Normalised and trimmed before its length is measured. A blocked name returns 403 `CONTENT_BLOCKED` 2 The accepted encoding, byte ceiling, and format set are the ones listed by [Create guild](#create-guild). No guild feature gates an animated icon on write, but the `a_` prefix is stripped from the returned hash while the guild lacks `ANIMATED_ICON` @@ -673,7 +671,7 @@ Every field is optional. An omitted field preserves its current value, and a fie 5 The channel must exist in this guild and be a voice channel, and is otherwise rejected with `AFK_CHANNEL_MUST_BE_IN_GUILD` or `AFK_CHANNEL_MUST_BE_VOICE` -6 A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW`. That check runs before the MFA level check +6 A guild with `DISCOVERABLE` cannot be lowered below LOW and is rejected with the field code `DISCOVERABLE_GUILD_VERIFICATION_LEVEL_TOO_LOW` 7 Sending the value the guild already holds needs neither ownership nor sudo mode, and an owner without a configured second factor is rejected with the field code `MUST_ENABLE_2FA_BEFORE_REQUIRING_FOR_MODS` @@ -721,7 +719,7 @@ Only a change to an audited field records a [`GUILD_UPDATE`](/http-api/guild-aud Removing `TEXT_CHANNEL_FLEXIBLE_NAMES` renames every guild text channel whose stored name does not satisfy the strict naming policy. When at least one channel is renamed, the removal emits one [Channel Update Bulk](/gateway/events/#channel-update-bulk) Dispatch with every channel in the guild. -Replacing an image queues the previous asset for deletion after the change succeeds. A failed replacement rolls back the newly uploaded asset and leaves the previous image unchanged. +Replacing an image removes the previous asset. A failed replacement leaves the previous image unchanged. ### Rate limit @@ -735,7 +733,7 @@ Permanently deletes the guild and returns 204 with an empty body. Requires the g Fluxer refuses a non-owner with 403 `MISSING_PERMISSIONS`. A bot can never own a guild, so a bot credential never satisfies the requirement. -A guild protected by an active single community policy cannot be deleted and returns 400 `SINGLE_COMMUNITY_CANNOT_DELETE`. That refusal comes before the ownership check and before sudo mode is verified, so a non-owner of such a guild receives the 400 rather than the 403. +A guild protected by an active single community policy cannot be deleted and returns 400 `SINGLE_COMMUNITY_CANNOT_DELETE`. ### Path parameters @@ -754,7 +752,7 @@ A guild protected by an active single community policy cannot be deleted and ret The body is the optional [sudo verification fields](#sudo-verification-fields). A request that already has a valid proof can omit it. :::danger[Deleting a guild destroys its messages and attachments] -There is no grace period and no scheduled deadline. The guild, its memberships, roles, channels, messages, attachments, invites, webhooks, and discovery listing are destroyed as the request runs, and nothing in the HTTP API restores them. +Deletion removes the guild, memberships, roles, channels, messages, attachments, invites, webhooks and discovery listing. There is no grace period or way to restore them through the API. ::: ### Response @@ -774,7 +772,7 @@ There is no grace period and no scheduled deadline. The guild, its memberships, ### Side effects -Every member session receives [Guild Delete](/gateway/events/#guild-delete) before any state is removed. The operation then removes every membership, deletes each member's guild settings, and removes the guild from each non-bot member's folder layout, which emits [User Settings Update](/gateway/events/#user-settings-update) to that member's sessions. It deletes every invite, webhook, message, attachment, and discovery record, and then the guild itself. +Every member receives [Guild Delete](/gateway/events/#guild-delete). Guild settings are removed. Non-bot members also receive [User Settings Update](/gateway/events/#user-settings-update) with the guild removed from their folder layout. No audit log entry is recorded, because the audit log is destroyed with the guild. An `X-Audit-Log-Reason` header on this request is read and discarded. @@ -804,7 +802,7 @@ A caller with no current membership receives 404 `UNKNOWN_MEMBER` whether or not 1 The deletion runs inside this request and completes before the membership is removed. The response is sent only after every matching message is gone -Setting `delete_messages` also changes the failure order. Fluxer resolves the guild and proves sudo mode before it reads the membership, so a non-member of an existing guild receives 403 `MISSING_PERMISSIONS` rather than 404 `UNKNOWN_MEMBER`. +With `delete_messages`, a non-member of an existing guild receives 403 `MISSING_PERMISSIONS` rather than 404 `UNKNOWN_MEMBER`. ### Request headers @@ -833,11 +831,11 @@ The optional body is the [sudo verification fields](#sudo-verification-fields). ### Side effects -The operation snapshots the membership's first join time, leave time, and any active communication timeout for one year, removes the membership, and decreases the guild's member count. For a non-bot account it also removes the guild from the account's folder layout, which emits [User Settings Update](/gateway/events/#user-settings-update) to that account's sessions. +The account leaves the guild. Non-bot accounts receive [User Settings Update](/gateway/events/#user-settings-update) with the guild removed from their folder layout. Remaining guild sessions receive [Guild Member Remove](/gateway/events/#guild-member-remove) and the leaving account's sessions receive [Guild Delete](/gateway/events/#guild-delete). -Fluxer leaves the guild's read states and per-guild settings in place. The membership row is destroyed with everything on it, so the guild nickname, guild avatar, guild banner, bio, pronouns, accent colour, and roles do not survive the leave. Rejoining within the retention window restores an unexpired communication timeout and nothing else. The leave records no audit log entry, and an `X-Audit-Log-Reason` header on this request is read and discarded. +Guild read states and settings are unchanged, but the guild profile and roles are lost. Rejoining within one year resumes any unexpired communication timeout. Leaving records no audit entry, and `X-Audit-Log-Reason` has no effect. ### Rate limit @@ -886,7 +884,7 @@ Every message the caller authored in the guild is destroyed together with its at ### Side effects -The operation deletes the caller's messages in this guild's channels in batches of at most 100 per channel. Each batch purges the messages' attachments and emits [Message Delete Bulk](/gateway/events/#message-delete-bulk) with that batch's IDs to the channel. No audit log entry is recorded. +The caller's messages and attachments are permanently deleted. Affected channels receive [Message Delete Bulk](/gateway/events/#message-delete-bulk) events with up to 100 message IDs each. No audit entry is recorded. ### Rate limit @@ -947,7 +945,7 @@ A non-null code also requires the `VANITY_URL` guild feature and is otherwise re 1 Fluxer lowercases the value, turns each whitespace run into one hyphen, and collapses repeated hyphens. The result must match `^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$` and be 2 to 32 characters -A result that fails the pattern is rejected with the field code `VANITY_URL_INVALID_CHARACTERS`, and one outside the length bound with `VANITY_URL_CODE_LENGTH_INVALID`. A code containing the term `fluxer` is rejected with the field code `VANITY_URL_CODE_CANNOT_CONTAIN_FLUXER`. Sending the code the guild already holds returns that code unchanged, after the feature and reserved term checks and before the uniqueness check. A code that any existing invite already holds is otherwise rejected with the field code `VANITY_URL_CODE_ALREADY_TAKEN`. +A result that fails the pattern returns `VANITY_URL_INVALID_CHARACTERS`, and one outside the length bound returns `VANITY_URL_CODE_LENGTH_INVALID`. A code containing `fluxer` returns `VANITY_URL_CODE_CANNOT_CONTAIN_FLUXER`. An unchanged valid code succeeds. A code claimed by another invite returns `VANITY_URL_CODE_ALREADY_TAKEN`. :::note[A custom invite code competes with ordinary invites] Selecting a code claims it in the global invite namespace, and replacing or removing the code releases it back. @@ -969,9 +967,8 @@ Selecting a code claims it in the global invite namespace, and replacing or remo ### Side effects -Supplying the current code, or removing a code when the guild has none, changes nothing, records no audit entry, and emits no Dispatch. Any other request deletes the invite backing the previous code. Where a code was supplied, it claims the new one. It records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry with the previous and new code. It also emits [Guild Update](/gateway/events/#guild-update) to every session that can see the guild, and [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions that can read the audit log. +An unchanged code has no side effects. A change replaces or removes the previous invite, records a [`GUILD_UPDATE`](/http-api/guild-audit-logs/#audit-actions) audit entry and emits [Guild Update](/gateway/events/#guild-update) and [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create). ### Rate limit 10 requests per minute for each authenticated user and guild, on the `guild:vanity_url:patch::guild_id` bucket. - diff --git a/fluxer_docs/src/content/docs/http-api/index.md b/fluxer_docs/src/content/docs/http-api/index.md index 03667fdb7..55a659d6e 100644 --- a/fluxer_docs/src/content/docs/http-api/index.md +++ b/fluxer_docs/src/content/docs/http-api/index.md @@ -4,35 +4,31 @@ title: HTTP API description: Request format, body representations, shared headers, cross-origin policy, and the error objects. --- -The Fluxer HTTP API is the set of routes a client calls to read and change data. Every route is published under `/v1`, and `/v1` is the only version. The base URL comes from the [instance discovery document](/http-api/instance/#get-instance-discovery), which is served unversioned at `/.well-known/fluxer`. A third-party client reads `endpoints.api_public` there, and the first-party web application reads `endpoints.api_client`. +Use the HTTP API to read and change resources. Discover the base URL through [`/.well-known/fluxer`](/http-api/instance/#get-instance-discovery). Third-party clients use `endpoints.api_public`, and the first-party web application uses `endpoints.api_client`. -Every route is also mounted at the root, so a path resolves with or without the prefix. A client MUST use the `/v1` form. [Download stored object](/http-api/downloads/#download-stored-object) is the one exception, and it resolves at the root alone. +Use `/v1`, the only API version. [Download stored object](/http-api/downloads/#download-stored-object) is unversioned and uses its documented root path. -Numeric limits such as attachment counts, expression counts, and profile field lengths are instance configuration. Each deployment publishes its current values in its [instance discovery](/http-api/instance/#limit-keys) document, so a client reads them at runtime. +Read resource limits from [instance discovery](/http-api/instance/#limit-keys). Attachment counts, expression counts and profile field lengths can differ between deployments. -[API conventions](/conventions/) defines the wire notation and the omission and `null` semantics. +[Field notation](/#field-notation) explains optional, nullable, and nullish fields. ## Request format -A JSON request body has `Content-Type: application/json`. A response body is UTF-8 JSON with `Content-Type: application/json` unless the operation states another representation. Message operations and webhook execution accept multipart bodies, and the OAuth2 token operations accept form-encoded bodies. +Send JSON bodies with `Content-Type: application/json`. Responses are UTF-8 JSON unless an operation states otherwise. Message operations and webhook execution also accept multipart bodies, and OAuth2 token operations accept form bodies. -The API applies no generic byte limit to a request body. An operation that accepts an upload bounds that upload itself. +An overloaded instance returns 503 `SERVICE_UNAVAILABLE` with `Retry-After: 1`. Wait before retrying. -Every request counts against one in-flight request ceiling for the whole instance. A request that arrives while the instance is at that ceiling returns 503 `SERVICE_UNAVAILABLE` with `Retry-After: 1` before the operation runs. The `/_health`, `/_healthz`, and `/_metrics` probe paths are exempt. +Unknown paths and unsupported methods return 404 `NOT_FOUND` without an `Allow` header. Trailing slashes are significant. -A request to a path that matches no route returns 404 `NOT_FOUND`. A request using a method the path does not register returns the same 404, and the response has no `Allow` header. Routing is strict, so a trailing slash is significant. - -The `GET` registered for a path also serves `HEAD`. A path that registers no `GET` serves no `HEAD` either. A `HEAD` response has the status and headers that `GET` returns, with no body. The request still reports `HEAD` as its method, so the [same-host origin check](#cross-origin-requests) can refuse a `HEAD` that has no `Origin` where the identical `GET` succeeds. +Every `GET` route supports `HEAD`, returning the same status and headers without a body. Routes without `GET` do not support `HEAD`. The [same-host origin requirement](#cross-origin-requests) also applies to `HEAD`. ## Request body formats -An operation documents its body under `JSON body`, `Form body`, or `Multipart body` and states any content-type restriction it enforces. +Each operation documents its accepted body format and fields. -A JSON body is parsed from the raw request text without inspecting `Content-Type`. The instance content filter scans a `POST`, `PUT`, or `PATCH` body that parses as JSON against the banned-phrase and banned-URL blocklists, whatever the header declares. It skips a body whose `Content-Type` contains `multipart/form-data` or `application/x-www-form-urlencoded`. A client MUST send the canonical media type. +Use `application/json` for JSON and `application/x-www-form-urlencoded` for OAuth2 form bodies. OAuth2 token operations also accept `multipart/form-data`. -Form bodies accept `application/x-www-form-urlencoded` and `multipart/form-data` interchangeably. The [OAuth2](/http-api/oauth2/) token operations are the only ones that take one. A field that occurs once is a string or file. Repeating the same field name produces an array in occurrence order, and a name ending in `[]` also collects its values into an array. - -[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) are the only operations that define a multipart body of their own. Each selects the multipart parser when the request `Content-Type` contains `multipart/form-data` and parses the body as JSON otherwise. The OAuth2 token operations also accept `multipart/form-data`, but read it as an ordinary form body. +[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message) and [Execute webhook](/http-api/webhooks/#execute-webhook) accept these multipart fields: | Field | Type | Description | | --- | --- | --- | @@ -53,7 +49,7 @@ Field-name failures are rejected with their own code: - More than one file supplied for one index returns `MULTIPLE_FILES_FOR_INDEX_NOT_ALLOWED`. - Where the resolved limit is 0, any file field at all returns `ATTACHMENTS_NOT_ALLOWED_FOR_MESSAGE`. -The `Content-Type` header supplies the multipart boundary. Each part's field name is in `Content-Disposition`. A body the multipart parser cannot read is rejected with `FAILED_TO_PARSE_MULTIPART_FORM_DATA`. A field name the operation does not recognise is ignored, and a `files[n]` part whose value is not a file is ignored once its index has been bounds-checked. +Include the multipart boundary in `Content-Type` and each field name in `Content-Disposition`. Malformed multipart bodies return `FAILED_TO_PARSE_MULTIPART_FORM_DATA`. Unknown fields and non-file values for valid `files[n]` indices are ignored. A multipart message body MAY also have `content`, `nonce`, `tts`, `flags`, `favorite_meme_id`, and `sticker_ids` as plain form fields. Each overrides the member of the same name in the parsed `payload_json`, and `sticker_ids` collects every value it is given. @@ -67,25 +63,23 @@ Each direct file's `files[n]` index is also the `id` in its attachment metadata ## Input normalisation -Field tables use the shared [wire table notation](/conventions/#wire-table-notation). - :::note[Empty values are normalised before validation] An empty string becomes `null` at any depth, and an empty nested object becomes `null` below the root of the payload. ::: -The shared validator normalises the JSON body, a form body, the query string, path parameters, request headers, and cookies before their schemas run. +This normalisation applies to JSON and form bodies, query strings, path parameters, request headers, and cookies. -A nested object whose members have all become `null` becomes `null` in turn. The root object itself is never collapsed this way. An empty request body is read as an empty object, so the caller sees the operation's own required-field failures. A body that is present but does not parse as JSON returns 400 `INVALID_FORM_BODY` with one element at path `body` and code `INVALID_FORMAT`. +A nested object containing only `null` values also becomes `null`. The root object is preserved. An empty body is treated as `{}` and validated for required fields. Malformed JSON returns 400 `INVALID_FORM_BODY` with a validation error at path `body` and code `INVALID_FORMAT`. -:::caution[These operations bypass the shared validator] -[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) read their own body and apply none of that normalisation. +:::caution[Message operations preserve empty values] +[Create message](/http-api/messages/#create-message), [Modify message](/http-api/messages/#modify-message), and [Execute webhook](/http-api/webhooks/#execute-webhook) do not apply this normalisation. ::: -An empty string stays an empty string and an empty nested object stays an empty object in those three. The first two reject a JSON body that does not parse. On a JSON body all collapse every schema failure to one validation entry, and each operation names that entry on its own page. +Those operations document their own JSON validation errors. ## Authentication -[Authentication](/authentication/) defines the accepted `Authorization` schemes, their exact token forms, and the OAuth2 scope registry. An operation that requires a credential states the scheme on its resource page. The [sudo verification object](/http-api/users/mfa/#sudo-verification-object) defines the sudo mode credential that guards sensitive account operations. +[Authentication](/authentication/) defines the accepted `Authorization` schemes and links to the OAuth2 scope registry. Each operation states which credentials it accepts. The [sudo verification object](/http-api/users/mfa/#sudo-verification-object) defines the proof required for sensitive account operations. An OAuth2 bearer access token is accepted only where a route opts in, and the resource page says so. Everywhere else a bearer credential is refused with 403 `ACCESS_DENIED`. An account with a suspicious activity flag is refused with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. @@ -96,7 +90,7 @@ These headers are accepted across resources. An operation-specific header is doc | Field | Type | Description | | --- | --- | --- | | Authorization? | string | The single credential for an authenticated request, in one of the [accepted schemes](/authentication/#authorization-schemes) | -| Content-Type?1 | string | The media type of the request body, which selects the multipart parser when it contains `multipart/form-data` | +| Content-Type?1 | string | Request body media type and, for multipart bodies, the boundary | | Accept-Language?2 | string | Selects the locale used for an error `message` | | X-Audit-Log-Reason?3 | string | Free-text reason recorded on the resulting audit log entry | | X-Fluxer-Client-Properties?4 | string | Base64-encoded JSON with the native client's `os`, read when an authentication session is created | @@ -107,7 +101,7 @@ These headers are accepted across resources. An operation-specific header is doc | User-Agent? | string | The originating client description recorded on a new authentication session and on an Admin audit entry | | Origin?8 | string | The browser origin used for cross-origin negotiation and for the mutating same-host origin check | -1 Only the multipart message operations read it to decide how the body is parsed. The instance content filter reads it separately, as described under [request body formats](#request-body-formats) +1 Use the media type specified in [request body formats](#request-body-formats) 2 The configured locale of the authenticated account takes precedence, so this header selects the locale only for an unauthenticated request or an account with no configured locale diff --git a/fluxer_docs/src/content/docs/http-api/instance.mdx b/fluxer_docs/src/content/docs/http-api/instance.mdx index a3a85081f..59fb641a5 100644 --- a/fluxer_docs/src/content/docs/http-api/instance.mdx +++ b/fluxer_docs/src/content/docs/http-api/instance.mdx @@ -6,7 +6,7 @@ description: Instance discovery, client geolocation, the limit key registry, and import RouteHeader from '@/components/RouteHeader.astro'; -The Instance resource describes how one Fluxer deployment is set up. It publishes the discovery document, the client geolocation lookup, and the served OpenAPI document, and it owns the [limit key](#limit-keys) registry. +Discover a deployment's endpoints, available features, and [limits](#limit-keys), look up the client's approximate location, or download its OpenAPI document. [Get instance discovery](#get-instance-discovery) is the entry point of the API. A client that knows only a Fluxer origin reads `/.well-known/fluxer` first, and that one unauthenticated response has every field of the [instance discovery object](#instance-discovery-object). Those values include the API base URLs, the [main Gateway](/gateway/overview/) WebSocket URL, and the [Media Proxy](/media-proxy/overview/) base URL. They override the [endpoint path defaults](#instance-endpoints-object) a deployment would otherwise serve from its canonical public origin. @@ -437,7 +437,7 @@ The approximate location Fluxer resolved for the request, together with the regi | ageRestrictedGeos | array[[geo entry](#geo-entry-object) object] | Locations where mature content requires an age check | | ageBlockedGeos | array[[geo entry](#geo-entry-object) object] | Locations where mature content is unavailable | -1 Every detected field is null when the deployment has configured no geolocation database, when the client address is not a valid IP address, when the database holds no record for it, or when the lookup fails. A record that resolves can still omit the subdivision code or the coordinate pair +1 All detected fields are null when the location is unknown. A known location can still omit the subdivision code or coordinates 2 The value is upper case @@ -493,9 +493,7 @@ The path has no `/v1` prefix. Returns the [geolocation object](#geolocation-object) for the request's client address. -The client address is the first hop of the deployment's configured client IP header, which defaults to `x-forwarded-for`. A lookup that resolves nothing still returns 200 with `countryCode`, `regionCode`, `latitude`, and `longitude` all null. A client MUST treat an all-null result as an unknown location. - -A resolved result is cached for ten minutes against the address. An IPv4 address is cached individually and an IPv6 address is cached by its `/64`. +A lookup that resolves nothing still returns 200 with `countryCode`, `regionCode`, `latitude`, and `longitude` all null. Treat this as an unknown location. ### Response @@ -513,7 +511,7 @@ A resolved result is cached for ten minutes against the address. An IPv4 address Returns the deployment's OpenAPI 3.1 document as JSON with `Content-Type: application/json; charset=utf-8`. -The document has no `ETag`. It declares one server entry, the deployment's `api_client` value from the [instance endpoints object](#instance-endpoints-object) followed by `/v1`. Every path key is relative to that entry. The server entry is the only part that varies by deployment, and the rest is fixed when the document is generated. +The document has no `ETag`. Its server URL is the deployment's `api_client` [endpoint](#instance-endpoints-object) followed by `/v1`. Every path is relative to that URL. ### Response diff --git a/fluxer_docs/src/content/docs/http-api/invites.mdx b/fluxer_docs/src/content/docs/http-api/invites.mdx index d453c8e7f..52a841d84 100644 --- a/fluxer_docs/src/content/docs/http-api/invites.mdx +++ b/fluxer_docs/src/content/docs/http-api/invites.mdx @@ -34,11 +34,11 @@ An invite object describes one code and the target it admits into. The `type` se 3 Null when the record stores no creator, which is always the case for a custom invite URL -4 A guild invite reports the counts the main Gateway holds, and both are `0` when it holds none +4 Guild counts are `0` when unavailable 5 Computed from the creation time plus the stored lifetime, and null whenever that lifetime is `0` -6 Enforced only for a guild invite. A group direct message invite stores and reports the flag without ever reading it +6 Has no effect on a group direct message invite :::note[Branch on `type` to know which fields arrive] A guild invite always has `guild`, `channel`, `member_count`, and `presence_count`. A group direct message invite has `channel` and `member_count` only. @@ -137,9 +137,9 @@ A partial recipient has only the username. ## Custom invite URLs -[Modify guild custom invite URL](/http-api/guilds/#modify-guild-custom-invite-url) creates and replaces a custom invite code and stores it as a guild invite record of type `0`. It stores no target channel, creator, maximum uses, or lifetime, so a resolved custom invite URL reports a null `inviter`, a null `expires_at`, `max_uses` of `0`, and `max_age` of `0`. Its channel is the first guild text channel in channel order that the default role can view. +[Modify guild custom invite URL](/http-api/guilds/#modify-guild-custom-invite-url) creates or replaces a custom invite code. It has type `0`, null `inviter` and `expires_at`, and `max_uses` and `max_age` of `0`. Its channel is the first guild text channel in channel order that the default role can view. -A custom invite code is stored lowercased, so [Get invite](#get-invite) and [Accept invite](#accept-invite) resolve it in any case through their lowercase retry. Clearing or replacing the code deletes the invite record outright, and a released code stops resolving immediately. +Custom invite codes are case-insensitive. Clearing or replacing a code makes the old link stop resolving immediately. [Accept invite](#accept-invite) records the join as a custom invite URL admission and attributes no inviter and no source invite code. [List channel invites](#list-channel-invites) and [List guild invites](#list-guild-invites) exclude custom invite URLs, and [Delete invite](#delete-invite) refuses them. @@ -151,14 +151,14 @@ Reads one invite. Returns an [invite object](#invite-object) on success. An `Authorization` header that cannot be resolved is ignored, and the route returns the same representation to every caller. The read consumes no use. -Fluxer retries a lookup that misses once against the lowercase form of the supplied code. A code allocated by [Create channel invite](#create-channel-invite) is drawn from a mixed-case alphanumeric alphabet and is matched exactly. +Codes from [Create channel invite](#create-channel-invite) are case-sensitive. Custom invite codes are case-insensitive. :::note[Every admission gate runs at accept time] Resolving an invite proves that the record exists and that its target is describable. [Accept invite](#accept-invite) evaluates bans, disabled invites, exhausted uses, missing presence, and target capacity. ::: -:::note[Expiry removes the record from storage] -A record's storage lifetime equals `max_age`. An exhausted record survives until an acceptance attempt or an explicit deletion removes it. +:::note[Lookup does not guarantee admission] +Expired invites no longer resolve. An exhausted invite may still resolve until an acceptance attempt removes it. ::: ### Path parameters @@ -195,8 +195,8 @@ A guild admission emits [Guild Create](/gateway/events/#guild-create), [Guild Me - User-only, so a bot token is rejected with 403 `ACCESS_DENIED`. - The caller needs no permission. -:::caution[Exhausting an invite deletes it] -An acceptance attempt against an invite whose maximum uses are already spent deletes the record before the request fails with 404 `UNKNOWN_INVITE`, so the failing caller removes it. A custom invite URL is never deleted this way. +:::caution[Exhausted invites stop working] +Accepting an exhausted invite returns 404 `UNKNOWN_INVITE` and makes the code stop resolving. Custom invite URLs have no use limit. ::: ### Path parameters @@ -227,7 +227,7 @@ An acceptance attempt against an invite whose maximum uses are already spent del ### Side effects -An account already in the target receives the invite unchanged without consuming a use or emitting a Dispatch. Otherwise `uses` advances exactly once after admission completes, and the record is deleted when that advance spends its last use. A failed admission consumes no use. Fluxer builds the response body after the admission has committed, through the same target resolution [Get invite](#get-invite) performs. A guild invite whose stored target channel has since been deleted therefore admits the account and advances `uses` before the request returns 404 `UNKNOWN_CHANNEL`. +An account already in the target receives the invite unchanged without consuming a use or emitting a Dispatch. A new admission consumes one use and removes the invite when its limit is reached. A failed admission consumes no use. If the invite's channel has been deleted, the account can still join and consume a use even though the response is 404 `UNKNOWN_CHANNEL`. A guild admission creates the membership, records whether the join used a custom invite URL or an instant invite, and adds the guild to the caller's settings and folder layout. A temporary invite marks the membership temporary. @@ -302,7 +302,7 @@ Creates an invite for a channel, or returns an existing equivalent invite. Retur - An age-restricted guild channel also requires an age-verified account. - A private channel requires the caller to be a current recipient, and no ownership is required. -`CREATE_INSTANT_INVITE` is read directly from the main Gateway, so this operation never requires elevated multi-factor authentication. +This operation does not require elevated multi-factor authentication. :::note[Guild affiliation determines the invite type] A channel that belongs to a guild creates a guild invite. Any other channel the caller can resolve creates a type `1` invite, including a direct message and the personal notes channel. Only a group direct message admits an account afterwards. @@ -329,7 +329,7 @@ Every field is optional and nullable, and an omitted or null value takes the sta 2 When false or omitted the operation returns an existing invite whose creator, target channel, maximum uses, lifetime, and temporary flag all match the request exactly. A custom invite URL never matches -3 Stored and never enforced on a private channel invite +3 Has no effect on a private channel invite ### Response @@ -350,9 +350,9 @@ Returning an existing equivalent invite changes nothing, records no audit entry, Creating an invite allocates a random eight-character code drawn from the mixed-case alphanumeric alphabet. A guild invite records an [INVITE_CREATE audit action](/http-api/guild-audit-logs/#audit-actions) targeting the invite code, with the channel, creator, maximum uses, lifetime, and temporary flag as metadata and the supplied reason, then delivers [Guild Audit Log Entry Create](/gateway/events/#guild-audit-log-entry-create) to sessions holding `VIEW_AUDIT_LOG`. Fluxer logs a failure to write that entry and still completes the request. The `X-Audit-Log-Reason` header is read but never recorded for a private channel. -The guild delivers [Invite Create](/gateway/events/#invite-create) to the sessions holding `MANAGE_CHANNELS` on the invite's channel, as [event filtering](/gateway/event-filtering/) states. The payload has no `channel_id` member, so that filter reads the channel from the nested `channel.id`. A passive session in a guild with more than 250 members is suppressed after that filter. A private channel invite records no audit entry and delivers Invite Create to every current recipient's sessions. +The guild delivers [Invite Create](/gateway/events/#invite-create) to sessions holding `MANAGE_CHANNELS` on the invite's channel, subject to [event filtering](/gateway/event-filtering/). A private channel invite records no audit entry and delivers the event to every current recipient's sessions. -The guild invite ceiling is the configured `max_guild_invites` value for the guild's feature set, which defaults to 1,000. It counts every guild invite across all channels, including the custom invite URL record, and Fluxer compares it before writing a new record. +The guild invite ceiling is its configured `max_guild_invites` limit, which defaults to 1,000. It includes invites across all channels and the custom invite URL. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/memes.mdx b/fluxer_docs/src/content/docs/http-api/memes.mdx index f44d70482..815cf2142 100644 --- a/fluxer_docs/src/content/docs/http-api/memes.mdx +++ b/fluxer_docs/src/content/docs/http-api/memes.mdx @@ -6,7 +6,7 @@ description: Saved favourite media and batch GIF URL resolution. import RouteHeader from '@/components/RouteHeader.astro'; -A meme is an image, video, or audio asset the current account has saved as a favourite. Every meme is a durable Fluxer copy held as an account-owned attachment, so it keeps working after the URL or message it came from disappears. +A meme is an image, video, or audio asset saved as a favourite. Its copy remains available after the source URL or message disappears. The routes here are user-only and operate on the caller's own collection. Fluxer rejects a bot token with 403 `ACCESS_DENIED`. [Create message](/http-api/messages/#create-message) sends a stored meme through its `favorite_meme_id` field, and the [GIF provider API](/http-api/gifs/) searches the configured provider for a GIF to save. @@ -203,9 +203,9 @@ Fetches an absolute media URL, stores a durable Fluxer copy, and returns the cre 4 Stored only when the request resolved to a provider GIF and the map is non-empty, and discarded otherwise -The stored filename is the last path segment of `url` after percent-decoding. Fluxer appends an extension inferred from the resolved content type when that segment has no dot. It then normalises the result so whitespace becomes `_` and every character outside letters, digits, combining marks, `_`, `.`, and `-` is dropped. A URL path with no last segment yields `media.{extension}`. Where the segment normalises to nothing, or to only dots and underscores, the filename is `unnamed`. The extension is `bin` when the content type maps to none. +The filename is derived from the URL and media type. Use the returned `filename` rather than constructing one. -Fluxer fetches the asset and resolves its metadata before it stores anything. A URL that resolves to no usable media fails with 400 `MEDIA_METADATA_ERROR`. When the request resolves to a provider GIF, the provider's canonical share URL is unfurled and the video content hash it reports replaces the fetched hash for [deduplication](#deduplication). +A URL that resolves to no usable media fails with 400 `MEDIA_METADATA_ERROR`. Saving media already in the collection fails as described under [Deduplication](#deduplication). ### Response @@ -222,8 +222,6 @@ Fluxer fetches the asset and resolves its metadata before it stores anything. A ### Side effects -The meme is created against a new attachment of its own holding the fetched bytes. - The operation emits one [Favorite Meme Create](/gateway/events/#favorite-meme-create) with the created object to the caller's own sessions. ### Rate limit @@ -265,7 +263,7 @@ When neither key selects anything, Fluxer takes the message's first attachment. Only an `image/*`, `video/*`, or `audio/*` asset can be saved. `attachment_id` is matched only when the message or one of its snapshots has at least one attachment. An ID matching none of them fails with `ATTACHMENT_ID_NOT_FOUND_IN_MESSAGE` at the `attachment_id` path. When the message has no attachment at all, Fluxer ignores the ID and takes the first usable embed. Only the selected attachment is tried, so a message whose chosen attachment has an unsupported type falls through to its embeds. A selection that resolves to no supported media fails with `NO_VALID_MEDIA_IN_MESSAGE` at the `media` path. -An embed selection uses the embed's image, video, or thumbnail, in that order. When the URL is one the instance's own media endpoint serves, Fluxer copies the asset without a fetch. Any other URL is fetched over the network, and a fetch that resolves no metadata fails with 400 `MEDIA_METADATA_ERROR`. +An embed selection uses its image, video, or thumbnail, in that order. Media whose metadata cannot be resolved fails with 400 `MEDIA_METADATA_ERROR`. ### Response @@ -290,7 +288,7 @@ A guild caller that can view the channel but lacks `READ_MESSAGE_HISTORY` receiv ### Side effects -Fluxer copies the selected asset into the account's own attachment space under a new attachment ID and creates the meme record against that attachment. Reading the source message renews its attachment decay deadline when attachment decay is enabled. The source message, its attachments, and its embeds are untouched. +The meme gets its own copy of the selected asset. Reading the source message renews its attachment decay deadline when attachment decay is enabled. Its content is otherwise unchanged. The operation emits one [Favorite Meme Create](/gateway/events/#favorite-meme-create) with the created object to the caller's own sessions. @@ -401,7 +399,7 @@ Every meme owns its stored bytes, so deletion removes them permanently. The meme ### Side effects -The operation removes the meme record and permanently deletes its stored copy. A storage failure is logged and does not fail the request, so the record is removed either way. +The meme is removed from the collection even if its stored copy cannot be deleted. It emits one [Favorite Meme Delete](/gateway/events/#favorite-meme-delete) with `meme_id` to the caller's own sessions. A request naming a meme the account does not own emits nothing. @@ -441,7 +439,7 @@ Fluxer derives a two-letter country from the requesting address by geolocation, 1 Exactly one entry per submitted URL, in the submitted order, so a client may index the result by position against its request -Fluxer resolves a URL in stages. It offers the URL to the configured GIF provider, then reads it as direct external media, and then unfurls it as a page when the direct read produced no renderable image or video. When a stage fails, Fluxer downgrades that URL's entry alone. A URL that none of the stages resolves still returns an entry, with its signed proxy URL, an empty `media` map, and whatever the direct read reported. Dimensions are zero and `content_type` is the empty string when the direct read reported nothing. +An unresolved URL still returns an entry with its signed proxy URL and an empty `media` map. Unavailable dimensions are zero and an unknown `content_type` is the empty string. One URL's failure does not fail the batch. ### Response diff --git a/fluxer_docs/src/content/docs/http-api/messages.mdx b/fluxer_docs/src/content/docs/http-api/messages.mdx index 8b9201593..055f72608 100644 --- a/fluxer_docs/src/content/docs/http-api/messages.mdx +++ b/fluxer_docs/src/content/docs/http-api/messages.mdx @@ -641,7 +641,7 @@ An `attachment://` reference on a request that has no attachment fails with the Fluxer issues an upload URL for an attachment before any message can reference it. [Request attachment upload URLs](#request-attachment-upload-urls) plans the upload. A plan whose declared size exceeds 10 MiB is a multipart plan, and [Complete attachment upload](#complete-attachment-upload) finishes it. -An `upload_url` is either a presigned object storage URL or a Fluxer [upload relay](/media-proxy/upload-relay/) URL, and the instance picks which per request from the caller's country. A caller whose IP is unknown, or whose country is not on the instance's direct-upload list, is given a relay URL. A client MUST send the URL exactly as issued and MUST NOT assume either shape. +An `upload_url` can point to object storage or the Fluxer [upload relay](/media-proxy/upload-relay/). Send it exactly as issued without assuming its shape. An upload key can be referenced only by a message that the same identity creates in the same channel. @@ -952,7 +952,7 @@ Fluxer checks each declared `file_size` separately against the attachment size l ### Side effects -The operation records one pending upload for each declaration against the caller and the channel, and opens a storage multipart upload for each multipart plan. It creates no message. +The operation reserves the uploads for the caller and channel. It creates no message. An issued `upload_filename` becomes an attachment only when a later [Create message](#create-message) or [Modify message](#modify-message) request references it from a [pre-uploaded attachment](#pre-uploaded-attachment-object) entry. A singlepart plan needs no completion step, so a message can reference its key as soon as the `PUT` succeeds. @@ -1013,7 +1013,7 @@ Fluxer reports the field code `UPLOADED_ATTACHMENT_NOT_FOUND` for an upload key ### Side effects -The operation finishes each multipart upload and records its completion time and completion IP. It creates no message, and the upload key stays available until a message consumes it. +The operation makes each completed upload available for a message. It creates no message. ### Rate limit @@ -1055,7 +1055,7 @@ The request body is read as a multipart form when `Content-Type` contains `multi | message_reference? | ?[message reference input](#message-reference-input-object) object | Reply or forward reference | | allowed_mentions? | ?[allowed mentions](#allowed-mentions-object) object | Active mention policy | | flags?3 | integer | [Message flags](#message-flags), defaulting to `0` | -| favorite_meme_id?4 | ?snowflake | Favorite meme to attach | +| favorite_meme_id?4 | ?snowflake | Favourite meme to attach | | sticker_ids? | ?array[snowflake] | At most 3 sticker IDs | | tts?5 | boolean | Whether to request text-to-speech | @@ -1197,7 +1197,7 @@ Modifies a message. Returns the updated [message](#message-object) object. Emits - `MANAGE_MESSAGES` is an [elevated permission](/http-api/permissions/#elevated-permissions), so a caller who holds it with no enrolled authenticator receives 400 [`TWO_FACTOR_REQUIRED`](/http-api/errors/) in a guild whose [MFA level](/http-api/guilds/#mfa-levels) is elevated, unless they own the guild. - A caller who does not hold the bit at all is treated as any other non-author and receives 403 `CANNOT_EDIT_OTHER_USER_MESSAGE`. - Embeds require [EMBED_LINKS](/http-api/permissions/) and adding an upload requires [ATTACH_FILES](/http-api/permissions/). -- The route takes a per-message write lock for the duration of the edit, and a request that cannot take it fails with 429 `RESOURCE_LOCKED` and a `Retry-After` of 1. +- A concurrent edit can fail with 429 `RESOURCE_LOCKED` and `Retry-After: 1`. The body is read exactly as it is for [Create message](#create-message), so a `multipart/form-data` request is accepted with the same `payload_json` and `files[N]` fields. A file that is not uploaded directly must first be planned through [Request attachment upload URLs](#request-attachment-upload-urls) and then referenced by its `upload_filename`. @@ -1283,7 +1283,7 @@ Deletes the authenticated identity's read state entry for one channel. Returns 2 ### Side effects -The operation deletes the caller's stored read-through marker, mention count, and pin acknowledgement for the channel, and invalidates the caller's cached unread badge count. It emits no Gateway Dispatch. +The operation deletes the caller's read-through marker, mention count, and pin acknowledgement for the channel. It emits no Gateway Dispatch. ### Rate limit @@ -1444,7 +1444,7 @@ Deletes every message in the authenticated user's personal notes channel. Return - The channel must be the caller's own `DM_PERSONAL_NOTES` channel, and any other channel fails with 400 `INVALID_CHANNEL_TYPE`. -The deletion runs inside the request in pages of 100 messages, so a channel with a very large history holds the request open until the last message is gone. +The response is returned only after deletion finishes, which can take longer for a large history. ### Path parameters @@ -2119,7 +2119,7 @@ Without `manual`, the marker advances to the larger of its current value and `me ### Side effects -The operation stores the new read-through marker and the supplied mention count for the caller and channel. It invalidates the caller's cached unread badge count, clears the channel's delivered notifications through the acknowledged watermark, and emits [Message ACK](/gateway/events/#message-ack) to the caller's own sessions only. +The operation updates the caller's read-through marker and mention count, clears delivered notifications through the acknowledged watermark, and emits [Message ACK](/gateway/events/#message-ack) to the caller's own sessions only. A non-manual acknowledgement whose `message_id` is below the stored watermark leaves the stored watermark and mention count unchanged. The Dispatch reports the stored values. diff --git a/fluxer_docs/src/content/docs/http-api/oauth2.mdx b/fluxer_docs/src/content/docs/http-api/oauth2.mdx index ba0f838ed..7a8b48b08 100644 --- a/fluxer_docs/src/content/docs/http-api/oauth2.mdx +++ b/fluxer_docs/src/content/docs/http-api/oauth2.mdx @@ -16,7 +16,7 @@ Proof Key for Code Exchange, or PKCE, supports the `S256` and `plain` challenge ## Credentials and lifetimes -Fluxer generates an authorisation code, a client secret, an access token, and a refresh token as 32 random bytes encoded as base64url, and a bot token is the application ID, a full stop, then such a secret. Retrying an interrupted exchange with the same inputs is rejected as an invalid grant, and the client restarts the grant. +Treat authorisation codes, client secrets, and tokens as opaque credentials. Retrying an interrupted exchange with the same inputs is rejected as an invalid grant. Restart the grant instead. An authorisation code expires 10 minutes after it is issued, an access token after 7 days, and a refresh token after 30 days. Every refresh exchange issues a replacement with a new 30 day life, so a grant ends 30 days after its last refresh. @@ -273,9 +273,9 @@ One application the current user has authorised, aggregated across its live refr | scopes1 | array[string] | The scopes aggregated across the user's live refresh tokens for the application | | authorized_at2 | ISO8601 timestamp | The earliest creation time among the refresh tokens that contributed to this entry | -1 The refresh token that first contributes an application contributes only its recognised scopes other than `bot`. Every further refresh token for the same application contributes its stored scope set unfiltered, so `bot` and an unrecognised value can appear once more than one refresh token contributes +1 Scopes have no defined order and can include `bot` or an unrecognised value. Ignore unrecognised values -2 Lowered by every refresh token that contributes to the entry, including one that has only `bot` +2 The earliest issue time among the application's current refresh grants An authorisation appears only while the application holds a live refresh grant with a recognised scope other than `bot`. diff --git a/fluxer_docs/src/content/docs/http-api/permissions.mdx b/fluxer_docs/src/content/docs/http-api/permissions.mdx index 3f590d575..da9b17965 100644 --- a/fluxer_docs/src/content/docs/http-api/permissions.mdx +++ b/fluxer_docs/src/content/docs/http-api/permissions.mdx @@ -393,7 +393,7 @@ Reorders the role hierarchy and returns 204 with an empty body. Requires [MANAGE - The caller needs [hierarchy authority](#role-hierarchy) over every role whose entry supplies a position differing from that role's stored `position`. - An entry that omits the field or repeats the stored value is accepted without that check. -Fluxer holds a guild-wide lock while the operation runs, so a concurrent position write on the same guild returns 423 `GENERAL_ERROR`. +A concurrent role position change in the same guild can return 423 `GENERAL_ERROR`. Every role the caller can manage is sorted by its requested position in descending order. A role that supplied no position keeps its stored position as the sort key, and any remaining tie is resolved by the current order. Fluxer then puts those roles back into the same set of slots they held before, so a role the caller cannot manage never moves. Every role other than the everyone role is then renumbered to a dense position. The highest-ranked role receives the number of roles other than the everyone role, the lowest-ranked role receives 1, and the everyone role stays at 0. @@ -440,7 +440,7 @@ Sets the member list position of one or more roles and returns 204 with an empty - The caller needs [hierarchy authority](#role-hierarchy) over every named role. - The everyone role cannot receive a hoist position. -A hoist position orders the separately displayed member list groups and is independent of the permission hierarchy. The operation holds a guild-wide lock, so a concurrent hoist position write on the same guild returns 423 `GENERAL_ERROR`. +A hoist position orders the separately displayed member list groups independently of the permission hierarchy. A concurrent hoist position change in the same guild can return 423 `GENERAL_ERROR`. ### Path parameters @@ -480,7 +480,7 @@ Clears the member list position of every role in the guild and returns 204 with - [Role hierarchy](#role-hierarchy) is not evaluated. The permission alone clears the hoist position of every role in the guild, including roles the caller does not outrank. -The operation takes the same guild-wide lock as [Modify role hoist positions](#modify-role-hoist-positions). +A concurrent hoist position change in the same guild can return 423 `GENERAL_ERROR`. ### Path parameters diff --git a/fluxer_docs/src/content/docs/http-api/premium.mdx b/fluxer_docs/src/content/docs/http-api/premium.mdx index 3c9774ac1..d07677e76 100644 --- a/fluxer_docs/src/content/docs/http-api/premium.mdx +++ b/fluxer_docs/src/content/docs/http-api/premium.mdx @@ -1,7 +1,7 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Premium -description: Premium pricing, entitlement state, mirrored billing data, and subscription self-service. +description: Premium pricing, entitlement state, billing data, and subscription self-service. --- import RouteHeader from '@/components/RouteHeader.astro'; @@ -24,8 +24,8 @@ Every route except [get price IDs](#get-price-ids) is user-only. A [self-hosted | [Billing payment method](#billing-payment-method-object) | One mirrored payment method | | [Premium pricing state](#premium-pricing-state-object) | The resolved checkout catalogue | -:::note[Billing history is mirrored from the payment provider] -[Get premium state](#get-premium-state) returns the mirrored invoices, payment methods and subscription fields. [Create customer portal](#create-customer-portal) is the externally hosted interface for changing any of them. +:::note[Manage billing through the customer portal] +[Get premium state](#get-premium-state) returns invoices, payment methods and subscription details. Use [Create customer portal](#create-customer-portal) to open the billing interface. ::: ## Premium types @@ -80,7 +80,7 @@ The checkout prices resolved for one country: monthly and yearly recurring, and 1 Fluxer resolves the recurring pair and the gift pair separately. Each pair takes the first currency for which both of its prices are configured, so the pairs can resolve to different currencies -2 Null when the amount cannot be resolved. [Get price IDs](#get-price-ids) reads it from the payment provider through a one-hour cache, while the copies inside [get premium state](#get-premium-state) read the mirrored price rows +2 Null when the amount is unavailable. Price changes can take up to one hour to appear ### Example @@ -103,7 +103,7 @@ The checkout prices resolved for one country: monthly and yearly recurring, and The price the account is billed against, and the current list price for the same billing cycle and currency. -[Get current subscription price](#get-current-subscription-price) reads the payment provider, and the `billing.current_subscription_price` copy inside [get premium state](#get-premium-state) reads the local mirror. The complete value is null when no subscription, no billing cycle, or no amount resolves for the account. +The complete value is null when the account's subscription, billing cycle, or amount is unavailable. ### Structure @@ -117,9 +117,9 @@ The price the account is billed against, and the current list price for the same | list_amount_minor4 | ?integer | The current list amount, in the minor unit of `currency` | | list_price_id4 | ?string | The current list price ID for the same billing cycle and currency | -1 The mirror-backed copy reports an empty string when neither the mirrored price row nor the mirrored subscription records a price identifier +1 The copy in [Get premium state](#get-premium-state) reports an empty string when the price identifier is unavailable -2 The subscription's own currency uppercased, so an unlisted currency is reported verbatim. The mirror-backed copy falls back to `USD` when neither source records a currency +2 The subscription's currency in upper case, including unlisted currencies. The copy in [Get premium state](#get-premium-state) defaults to `USD` when unavailable 3 True only when a list price is configured for the same billing cycle and currency and its identifier differs from `price_id`, so an unconfigured currency reports false @@ -141,7 +141,7 @@ The price the account is billed against, and the current list price for the same ## Pending subscription change object -A billing cycle change that [change subscription billing cycle](#change-subscription-billing-cycle) scheduled for the end of the current period. [Premium billing state](#premium-billing-state-object) has it as its only live member, read from the payment provider on every request. No route returns it on its own. +A billing cycle change scheduled for the end of the current period. It is returned in [Premium billing state](#premium-billing-state-object), not through a separate route. ### Structure @@ -290,7 +290,7 @@ The effective state decides whether premium features are available. It differs f The account's billing data behind a premium screen. -`stripe_customer_id`, `current_subscription_price`, `subscription`, `invoices` and `payment_methods` are read from the local mirror of the payment provider. Fluxer repairs the payment method mirror while building the response. `refund_eligibility` is computed from the mirrored invoices. `pending_subscription_change` is read live from the payment provider on every request. +Billing details can lag behind changes made through the payment provider. ### Structure @@ -305,7 +305,7 @@ The account's billing data behind a premium screen. | payment_methods3 | array[[billing payment method](#billing-payment-method-object)] | The stored payment methods | | refund_eligibility4 | [refund eligibility](/http-api/billing/#refund-eligibility-object) object | The self-service refund state for the account | -1 At most 12 entries, ordered by provider creation time descending. Fluxer collects invoices across every payment provider customer the account owns and deduplicates them by provider identifier before the cut +1 At most 12 distinct invoices across the account's billing history, newest first 2 True when the account has more invoices than the 12 returned. No route pages past the first 12, so use [create customer portal](#create-customer-portal) for the rest @@ -415,7 +415,7 @@ One mirrored stored payment method. A non-card method reports every card member | card_exp_year | ?integer | The expiry year of the card, or null when the method is not a card | | is_default1 | boolean | Whether the method is the customer's default | -1 Recomputed against the default of the owning payment provider customer during the repair, so an account owning more than one customer can report more than one default +1 An account with more than one billing customer can report more than one default ### Example @@ -481,14 +481,14 @@ The deployment must configure a complete recurring pair and a complete gift pair Returns the [premium state](#premium-state-object) object for the authenticated account. -The route answers from mirrored billing data, so it works when the payment provider is unconfigured. With a configured provider it also repairs missing payment method mirror rows for the account's customers before building the response. A repair failure is logged and absorbed. +The route is available even when no payment provider is configured. :::note[One read serves the whole billing screen] `billing.current_subscription_price` and `billing.refund_eligibility` cover the same ground as [get current subscription price](#get-current-subscription-price) and [get refund eligibility](/http-api/billing/#get-refund-eligibility), and `billing.pending_subscription_change` has no route of its own. ::: -:::note[The paired values are computed differently] -This route derives `current_subscription_price` from the mirrored subscription and `refund_eligibility` from the mirrored invoices, while the dedicated routes read the payment provider. The values can disagree while the mirror is behind. +:::note[Billing details may take time to update] +`current_subscription_price` and `refund_eligibility` can temporarily differ from the dedicated routes after a billing change. ::: ### Query parameters @@ -508,7 +508,7 @@ This route derives `current_subscription_price` from the mirrored subscription a ### Side effects -The read can write to the payment provider and to the local billing mirror while repairing payment method state, including aligning the customer default payment method with the subscription default payment method. It does not change entitlement. +The read can update the customer's default payment method to match the subscription. It does not change entitlement. ### Rate limit @@ -520,9 +520,9 @@ The read can write to the payment provider and to the local billing mirror while Returns the [current subscription price](#current-subscription-price-object) object for the authenticated account, or null when no price can be resolved. -The route reads the subscription from the payment provider, so it returns null when the deployment configures no payment provider and when the account has no stored subscription identifier. A provider read that fails is logged and reported as null. +Returns null when no payment provider or subscription is configured, or when the price lookup fails. -Fluxer caches the resolved value for 5 minutes for each subscription. It reads `list_amount_minor` through a second cache that holds each list price amount for 1 hour, so a list price change made in the payment provider can take an hour to appear. `is_grandfathered` compares price identifiers and does not read that cache. +Subscription price changes can take 5 minutes to appear. List price amount changes can take one hour. ### Response @@ -558,7 +558,7 @@ The response is built exactly as [get premium state](#get-premium-state) builds ### Side effects -When the requested value differs from the current one, the operation updates the account and sends [User Update](/gateway/events/#user-update) to every session the account owns. Repeating the current value leaves the flag untouched. The returned `actual` state never changes. Building the response runs the same payment method repair as [get premium state](#get-premium-state), and either call can write to the payment provider and to the local billing mirror. +When the requested value changes, every account session receives [User Update](/gateway/events/#user-update). Repeating the current value leaves the flag untouched. The returned `actual` state never changes. The response has the same billing side effects as [Get premium state](#get-premium-state). ### Rate limit @@ -604,7 +604,7 @@ The window this operation ends runs to `premium_grace_ends_at`. [Receive Stripe | The subscription ended early | The subscription end itself, which leaves no window for this route to close | | The early cancellation is already recorded | Nothing | -A background reconciliation of the account against the payment provider records the same early cancellation deadline when it finds a terminal subscription that ended before the recorded premium end. `has_active_paid_premium` treats a missing deadline as exactly 3 days after `premium_until`, but this route requires the recorded deadline. +This route requires an explicit `premium_grace_ends_at`. It does not end the default 3-day grace period used when that field is absent. :::caution[The downgrade is immediate] Premium features become unavailable before the recorded grace deadline arrives. The grace period cannot be restarted through this API. @@ -776,7 +776,7 @@ A subscription set to cancel has no next billing period, so the switch would hav - `feature_unavailable`: the deployment configures no payment provider - `no_active_subscription`: the account has no stored subscription -- `subscription_not_chargeable`: the subscription is neither active nor trialing +- `subscription_not_chargeable`: the subscription is neither active nor on a trial - `unsupported_subscription`: the subscription has no recurring primary item with a resolvable amount, currency and interval - `no_list_price`: no list price is configured for that currency and billing cycle - `already_on_list_price`: the subscription already bills on the current list price diff --git a/fluxer_docs/src/content/docs/http-api/read-states.mdx b/fluxer_docs/src/content/docs/http-api/read-states.mdx index 6b71e385f..f8f3797be 100644 --- a/fluxer_docs/src/content/docs/http-api/read-states.mdx +++ b/fluxer_docs/src/content/docs/http-api/read-states.mdx @@ -124,11 +124,7 @@ The route resolves no channel and evaluates no permission. The write changes onl 1 One entry for each submitted acknowledgement, in the submitted order. An entry whose watermark did not move reports the values already stored -:::note[A plain list takes the batched path] -When no entry sets `manual` and no entry has a `mention_count` above `0`, Fluxer applies the whole list through the batched path [Mark channels as read](#mark-channels-as-read) uses. -::: - -A list with one manual entry or one positive mention count takes the entry-by-entry path in submitted order. On that path, a later entry naming the same channel is evaluated against the earlier entry's result. +When any entry is manual or has a positive mention count, entries for the same channel apply in submitted order. Otherwise, each entry is evaluated against the state before the request. ### Response @@ -139,11 +135,11 @@ A list with one manual entry or one positive mention count takes the entry-by-en ### Side effects -Each entry updates its channel watermark according to the entry's `manual` value and stores the submitted mention count. The write invalidates the account's unread badge total. +Each entry updates its channel watermark according to `manual` and sets the submitted mention count. -One [Message ACK](/gateway/events/#message-ack) goes to the caller's own sessions for every submitted entry, including one that leaves the watermark and mention count unchanged. The Dispatch has the resulting `message_id`, `mention_count`, and `version`, so a non-manual entry below the stored watermark reports the higher stored value. The entry-by-entry path also has the submitted `manual` value, and the batched path has no `manual` at all. +One [Message ACK](/gateway/events/#message-ack) goes to the caller's sessions for every submitted entry, including an unchanged one. It reports the resulting `message_id`, `mention_count`, and `version`. It also includes the submitted `manual` value when any entry is manual or has a positive mention count. -Fluxer logs a failed Dispatch or a failed unread badge invalidation on either path and does not fail the request. A client that misses a Dispatch reconciles from a later read. +A successful acknowledgement can have no corresponding Dispatch. Reconcile from a later read when needed. Every entry also clears delivered notifications for its channel through the submitted `message_id`, so a discarded or rewound entry clears a different range than the entry stores. @@ -176,11 +172,11 @@ Every entry advances its channel watermark monotonically and sets the channel's ### Side effects -An entry equal to the stored watermark still produces a write, and two entries naming the same channel produce two writes. The unread badge total is invalidated once for the whole request. +An entry equal to the current watermark resets the mention count to `0`. One [Message ACK](/gateway/events/#message-ack) goes to the caller's own sessions for every submitted entry, including a skipped one. Every entry clears delivered notifications for its channel through the submitted `message_id`. The Dispatch has no `manual`, and a skipped entry reports the stored watermark and mention count. -Fluxer logs a failed Dispatch or a failed unread badge invalidation and does not fail the request, so a client tolerates a missing Dispatch for an entry the write applied. +A successful acknowledgement can have no corresponding Dispatch. :::note[A failed request can leave some entries written] A request that fails partway leaves the entries already written in place. A retry is safe, because a repeated monotonic acknowledgement is idempotent. diff --git a/fluxer_docs/src/content/docs/http-api/reports.mdx b/fluxer_docs/src/content/docs/http-api/reports.mdx index 9e24cb53d..8da7318ac 100644 --- a/fluxer_docs/src/content/docs/http-api/reports.mdx +++ b/fluxer_docs/src/content/docs/http-api/reports.mdx @@ -370,7 +370,7 @@ One reporter creates at most 5 reports each hour across all report types. Issues a Digital Services Act verification code for an email address and returns a [success object](#success-object). -The address must be deliverable. A domain publishing no MX record and no A or AAAA record returns 400 `INVALID_FORM_BODY` with the validation code `INVALID_EMAIL_ADDRESS` on `email`. A DNS lookup that fails for a transient reason is treated as deliverable. A domain result is cached for 30 minutes when it resolves and 5 minutes when it does not. +The address must be deliverable. A domain with no MX, A, or AAAA record returns 400 `INVALID_FORM_BODY` with `INVALID_EMAIL_ADDRESS` on `email`. A temporary DNS failure does not reject the address. ### JSON body @@ -389,9 +389,9 @@ The address must be deliverable. A domain publishing no MX record and no A or AA ### Side effects -The operation stores one verification record for the normalised address. The record expires 10 minutes after issue. It is keyed by address alone, so sending again for the same address replaces the previous code and restarts the 10 minute window. The code is nine characters in `XXXX-XXXX` form drawn from uppercase letters and digits. +The code expires after 10 minutes. Sending again to the same address replaces the previous code and restarts that window. Codes use uppercase letters and digits in `XXXX-XXXX` form. -Fluxer attempts delivery inside the request and discards the outcome. The operation creates no ticket and emits no Gateway Dispatch. +The operation creates no ticket and emits no Gateway Dispatch. :::caution[A 200 covers every delivery outcome] The response is 200 when the provider accepts the message and when it refuses it. It is 200 for an address recorded as a hard bounce, and on an instance with email delivery switched off. diff --git a/fluxer_docs/src/content/docs/http-api/search.mdx b/fluxer_docs/src/content/docs/http-api/search.mdx index f6ff6089b..65b02349a 100644 --- a/fluxer_docs/src/content/docs/http-api/search.mdx +++ b/fluxer_docs/src/content/docs/http-api/search.mdx @@ -10,7 +10,7 @@ Message search matches indexed messages against a text query and a filter set. O Fluxer resolves the requested scope to the set of channels the caller may search, then runs the query over that set. A private channel search covers the authenticated account alone, and the set holds a guild channel only while the caller can read its history. -An instance backs message search with either Elasticsearch or Meilisearch. Term matching and typo tolerance belong to whichever it runs, so an instance can differ in how forgiving a match is. Filter matching is exact in every case. +Term matching and typo tolerance can vary by deployment. Filter matching is exact. ## Message search result object @@ -37,7 +37,7 @@ The body a completed search returns, holding one page of matching messages. [Search messages](#search-messages) never honours a supplied cursor, so page through a result set with `page` alone. -The operation drops a hit whose message no longer exists. On an offset page the operation re-walks the result set so that `total` and the returned page both count only live messages. A request that supplied a `cursor` skips that reconciliation and only removes the stale hits, so the page can be shorter than `hits_per_page` while further pages exist. +Deleted messages are omitted. When using `page`, the total counts only existing messages. Supplying a `cursor` can leave shorter pages even when more results exist. ### Example @@ -250,10 +250,6 @@ A `current` guild context rejects a requested channel outside that guild with 40 Fluxer compares `attachment_extension` without lowercasing it, so an uppercase value never matches. `include_nsfw` gates which channels a resolved scope keeps. -:::caution[One stale hit re-reads the whole result set] -The operation walks the set from the first page to skip stale hits, so a deep offset page costs proportionally more and returns no cursor. -::: - :::caution[Elasticsearch has a 10000 document result window] A plain text query pages past it. A page beyond that window fails with 500 `INTERNAL_SERVER_ERROR` when `contents` or `exact_phrases` is supplied. ::: diff --git a/fluxer_docs/src/content/docs/http-api/streams.mdx b/fluxer_docs/src/content/docs/http-api/streams.mdx index d02e54621..f91573984 100644 --- a/fluxer_docs/src/content/docs/http-api/streams.mdx +++ b/fluxer_docs/src/content/docs/http-api/streams.mdx @@ -81,7 +81,7 @@ A short-lived URL that accepts one stream's preview image. [Create stream previe A client MUST send the URL exactly as returned and MUST NOT rebuild or reorder it. Rebuilding drops the signed capability in `t`, and a relay URL that arrives without `t` returns 401. -The capability keeps accepting `PUT` requests until it expires. Nothing records that it has been redeemed, and each request overwrites the object the previous one wrote. +The URL accepts repeated `PUT` requests until it expires. Each replaces the previous preview. ### Example @@ -229,9 +229,7 @@ A `thumbnail` that is not [canonical base64](/topics/uploads/#stream-previews) r The decoded bytes replace whatever preview the stream key already had, and the preview metadata expires one day after the write. Fluxer emits no Gateway Dispatch, so a viewer observes the new image on its next [Get stream preview](#get-stream-preview) read. -Fluxer absorbs a transient object storage failure. The request still answers 204 and the previous preview remains readable, so a publisher that needs the image present re-uploads on its normal interval. - -A transient failure of the metadata write also answers 204. The next read returns the new bytes under the `Content-Type` and the expiry recorded by the previous write. When no metadata existed at all, the object is stored and no read finds it. +A 204 does not guarantee that the new preview is readable. A temporary failure can leave the previous image, content type, or expiry in place, or leave no readable preview. Publishers should continue uploading on their normal interval. ### Rate limit @@ -277,7 +275,7 @@ Returns a [stream preview upload object](#stream-preview-upload-object) granting ### Side effects -The operation issues one bounded upload capability and immediately points the stream key's preview metadata at the object that capability writes to. There is no separate completion call. The preview becomes readable as soon as the `PUT` lands, and until then a [Get stream preview](#get-stream-preview) read answers an empty 404 or still returns the previous image. +There is no separate completion call. After the `PUT` succeeds, [Get stream preview](#get-stream-preview) returns the uploaded image. Until then it returns an empty 404 or the previous image. A client MUST send the returned `content_type` with the `PUT` and MUST NOT exceed `max_bytes`. The preview metadata expires one day after this call, so a capability used later than that writes an object no read can find. Fluxer emits no Gateway Dispatch. @@ -317,7 +315,7 @@ Deletes the stored preview for a stream and returns 204 with an empty body. Dele ### Side effects -Fluxer drops the preview metadata first and removes the stored object second, so the preview stops being readable even when a transient storage failure leaves the object in place. Fluxer emits no Gateway Dispatch, and a viewer observes the removal on its next [Get stream preview](#get-stream-preview) read. +The preview stops being readable. No Gateway Dispatch is emitted, so viewers observe the removal on their next [Get stream preview](#get-stream-preview) request. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/themes.mdx b/fluxer_docs/src/content/docs/http-api/themes.mdx index 32b5431fd..bb6677f1d 100644 --- a/fluxer_docs/src/content/docs/http-api/themes.mdx +++ b/fluxer_docs/src/content/docs/http-api/themes.mdx @@ -12,7 +12,7 @@ The route is user-only. A bot token and an OAuth2 bearer credential are both rej ## Theme identifier -A theme identifier is 16 lowercase hexadecimal characters matching `^[a-f0-9]{16}$`. Fluxer draws eight cryptographically random bytes for each request and renders them as hex, so storing identical CSS twice produces two independent themes under two independent identifiers. +A theme identifier is 16 lowercase hexadecimal characters matching `^[a-f0-9]{16}$`. Submitting identical CSS twice creates two independent themes. ### Example @@ -93,7 +93,7 @@ The object has the identifier and nothing else. Fluxer stores the UTF-8 encoding of the submitted document with the content type `text/css; charset=utf-8`. The identifier addresses the document by the time the caller receives it. -Nothing Fluxer stores links a theme to the account that created it. The [Media Proxy](/media-proxy/overview/) serves the stored document from `/themes/{id}.css`. +The [Media Proxy](/media-proxy/overview/) serves the document from `/themes/{id}.css`. :::danger[Theme CSS is public and permanent] The identifier is the only thing guarding a stored document, and the read route is unauthenticated. The HTTP API replaces or deletes no stored theme. A caller MUST NOT put a credential, a token, or any other secret in a submitted document. diff --git a/fluxer_docs/src/content/docs/http-api/unfurl.mdx b/fluxer_docs/src/content/docs/http-api/unfurl.mdx index 84da0cf3c..24d841821 100644 --- a/fluxer_docs/src/content/docs/http-api/unfurl.mdx +++ b/fluxer_docs/src/content/docs/http-api/unfurl.mdx @@ -1,7 +1,7 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Unfurl -description: On-demand URL resolution, its site resolvers, network policy, and cache behaviour. +description: Resolve a public URL into embed previews. --- import RouteHeader from '@/components/RouteHeader.astro'; @@ -12,23 +12,23 @@ The route is user-only. Fluxer rejects a bot token and an OAuth2 bearer credenti ## Site resolvers -Fluxer walks a fixed chain of resolvers in the order below. A resolver runs only when the URL matches it, and the first resolver that produces at least one embed wins. Fluxer skips a resolver that produces nothing and a resolver whose own fetch fails, then tries the next match. An unavailable provider therefore falls back to the generic resolver. +Supported sites include the following. Preview availability depends on the site and deployment configuration. Other public URLs can produce previews from their page metadata or media content. -| Resolver | Matched URL | Source | -| --- | --- | --- | -| Hacker News1 | `news.ycombinator.com` with a path beginning `/item` | The Hacker News item API | -| Klipy23 | `klipy.com` and `www.klipy.com` | The Klipy media API | -| Tenor | `tenor.com` | The page markup and its JSON-LD block | -| xkcd | `xkcd.com` | The comic page markup | -| YouTube24 | `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com`, `youtube-nocookie.com`, `www.youtube-nocookie.com`, and `youtu.be` | The YouTube Data API | -| Wikipedia5 | `wikipedia.org`, `www.wikipedia.org`, and the language hosts `en`, `de`, `fr`, `es`, `it`, `ja`, `ru`, and `zh`, with a path beginning `/wiki/` | The Wikipedia REST page summary API | -| Bluesky6 | `bsky.app` | The Bluesky XRPC API | -| FxTwitter7 | `fxtwitter.com`, `fixupx.com`, `twittpr.com`, `xfixup.com`, and any subdomain of those | The FxTwitter status API | -| Generic8 | Every URL | The URL itself | +| Site | Matched URL | +| --- | --- | +| Hacker News1 | `news.ycombinator.com` with a path beginning `/item` | +| Klipy23 | `klipy.com` and `www.klipy.com` | +| Tenor | `tenor.com` | +| xkcd | `xkcd.com` | +| YouTube24 | `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com`, `youtube-nocookie.com`, `www.youtube-nocookie.com`, and `youtu.be` | +| Wikipedia5 | `wikipedia.org`, `www.wikipedia.org`, and the language hosts `en`, `de`, `fr`, `es`, `it`, `ja`, `ru`, and `zh`, with a path beginning `/wiki/` | +| Bluesky6 | `bsky.app` | +| FxTwitter7 | `fxtwitter.com`, `fixupx.com`, `twittpr.com`, `xfixup.com`, and any subdomain of those | +| Generic8 | Every URL | 1 The item identifier is read from the `id` query parameter, and a URL that has none produces no embed -2 The Klipy and YouTube resolvers rewrite the URL to its canonical provider form before anything is fetched, and the generic resolver sees that rewritten URL +2 Klipy and YouTube preview URLs use their canonical provider form 3 Requires the instance to have a Klipy API key configured, and produces nothing without one @@ -42,8 +42,6 @@ Fluxer walks a fixed chain of resolvers in the order below. A resolver runs only 8 Builds a direct media embed when the response is an image, video, or audio asset. A final response whose status is not 200 produces no embed -The generic resolver otherwise decodes the document and assembles the embed from its ActivityPub representation, its oEmbed document, and its Open Graph and Twitter card metadata. For a MediaWiki page it also reads the article extract. - A resolved embed has at most one nested embed in `children`, and a nested embed has no `children` of its own. ## Network policy @@ -58,23 +56,13 @@ Fluxer applies its network policy before every fetch and again to every redirect - The host resolves to a loopback, private, link-local, or otherwise reserved address. - The host resolves to no address at all. -Every HTTP request has a timeout of at most 10 seconds. The FxTwitter timeout is 8 seconds, and a Hacker News, Wikipedia, Bluesky, ActivityPub, oEmbed, MediaWiki extract, or media metadata call has 5 seconds. Every fetch identifies itself as `Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)`. - -Fluxer abandons a fetch that follows more than five redirects, that revisits a URL already followed in the same chain, or whose response body exceeds the byte budget of that fetch. That budget is 8388608 bytes for a document fetch and lower for a provider API call. - -:::note[The whole resolution has a 12-second deadline] -Each redirect hop is a fresh request with its own bound, so one fetch that follows redirects can run for a multiple of the per-request timeout. -::: +Resolution has a 12-second deadline. A fetch fails on a redirect loop, more than five redirects, or a document larger than 8 MiB. A refused or failed fetch does not by itself fail the operation. When Fluxer produces no embed, the response is 200 with an empty array. ## Cache behaviour -Two requests share a cached result only when they have the same URL and the same media scanning mode, and the instance's provider API keys did not change between them. - -Fluxer holds a result with at least one embed for the instance's configured entry lifetime, and a result with none for 60 seconds. - -A message whose URL is already cached is created with the embed attached. A URL that is not cached is resolved after the message is created, and the message is then updated with the result. +This endpoint requests a fresh resolution. Messages may reuse an earlier preview or receive one in a later message update. ## Resolve URL embeds @@ -98,18 +86,12 @@ No permission applies. Outside a development instance, Fluxer also rejects a URL that omits a top-level domain. -The route always asks for the explicit media classifier, so a media object the classifier marks has `CONTAINS_EXPLICIT_MEDIA` in its [flags](/http-api/messages/#attachment-flags). The Klipy resolver is the one exception. It reads its thumbnail and video metadata with the classifier disabled regardless of what the request asked for, and Klipy media never has that flag. - -The message path asks the same way, except in a channel that permits explicit media, where the classifier is disabled for every resolver. The same URL can resolve to differently flagged media there. - -That path also extracts its candidate URLs from message text. It drops anything inside a code span or code fence and anything wrapped in angle brackets. It skips a Fluxer invite, the invite host, the gift host, the web application host, the marketing host under `/channels/` and `/theme/`, and the instance's configured `unfurl_ignored_hosts`. It then unfurls at most the first five surviving URLs. One message has at most 10 embeds, and Fluxer scans every resolved URL and media URL for banned content before it attaches an embed. A scan that blocks the content deletes the message. +Explicit media can carry `CONTAINS_EXPLICIT_MEDIA` in its [flags](/http-api/messages/#attachment-flags). Klipy media is not classified. Message previews in channels that permit explicit media can have different flags. :::note[This route applies none of the message-path filtering] It resolves precisely the URL in the request body and returns the complete resolver output, with no URL extraction, no embed cap, and no banned-content scan. ::: -The request never reads a cached result, so Fluxer resolves the URL live every time. - ### Response | Status | Body | Condition | @@ -122,7 +104,7 @@ The request never reads a cached result, so Fluxer resolves the URL live every t ### Side effects -The operation fetches the supplied URL, can call a provider API for a matched site, and fetches resolved media assets so the classifier can read them. Fluxer writes the result to the unfurl cache, where a later message operation can reuse it. +Resolution can fetch the URL and its linked media. It creates no message. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/users.mdx b/fluxer_docs/src/content/docs/http-api/users.mdx index 342c0e15c..607db18a3 100644 --- a/fluxer_docs/src/content/docs/http-api/users.mdx +++ b/fluxer_docs/src/content/docs/http-api/users.mdx @@ -159,7 +159,7 @@ The private representation of the current account, returned by [Get current user 5 For an OAuth2 bearer credential without the `email` [scope](/http-api/oauth2/#oauth2-scopes), `email` is `null` -6 The account record stores no phone number. The field is retained so an older client keeps parsing the response +6 Always null 7 The pair is present only while the account has the staff flag, and it is absent for every other account @@ -185,7 +185,7 @@ The private representation of the current account, returned by [Get current user 18 The value is false while premium entitlements are not active, even when a dismissal was recorded earlier -19 Both values derive from the difference between the stored gift inventory sequence and the sequence the account has acknowledged, and they report `false` and `0` when no gift has ever been recorded +19 Reports whether unread gifts exist and how many. An account with no gifts reports `false` and `0` :::note[A bearer read returns zero values] `acls`, `traits`, and `required_actions` become empty arrays. `email_bounced` and `authenticator_types` are dropped. Every field marked 3 above that is a boolean becomes `false`, every timestamp or nullable field becomes `null`, and `unread_gift_inventory_count` becomes `0`. @@ -640,7 +640,7 @@ A read that passes the access check can still be restricted. A restricted read n ### Side effects -When the target's premium period and grace interval have both elapsed, this read clears the stored premium state on the target account before returning it. The cleared fields are the premium type, activation time, end time, gift extension, cancellation flag, billing cycle, and grace end. That write emits no Gateway Dispatch. The background sweep that performs the same clearing emits [User Update](/gateway/events/#user-update). The profile is still returned when the clearing write fails. +Reading a profile can clear expired premium state after its grace period. That change emits no Gateway Dispatch and does not prevent the profile from being returned. ### Rate limit @@ -663,7 +663,7 @@ Reports whether a username and discriminator pair is unavailable. 2 The value is parsed as a decimal integer before the lookup, so `7`, `07`, and `0007` describe the same tag -Fluxer reports `false` for the caller's own current tag without a lookup. That comparison reads the username case-insensitively and the discriminator as an integer. +The caller's current tag returns `false`. The username comparison is case-insensitive and the discriminator is compared as an integer. A reserved username fails validation with `USERNAME_RESERVED_VALUE` or `USERNAME_CANNOT_CONTAIN_RESERVED_TERMS`, and no `taken` value comes back for it. diff --git a/fluxer_docs/src/content/docs/http-api/users/content.mdx b/fluxer_docs/src/content/docs/http-api/users/content.mdx index a7378c60b..5a39e40ef 100644 --- a/fluxer_docs/src/content/docs/http-api/users/content.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/content.mdx @@ -12,8 +12,8 @@ These routes are user-only. Fluxer rejects a bot or OAuth2 bearer credential wit Every route except [Delete current user's messages](#delete-current-users-messages) reaches the main Gateway, and a Gateway failure returns 502 `BAD_GATEWAY`, 503 `SERVICE_UNAVAILABLE`, or 504 `GATEWAY_TIMEOUT`. [Save message](#save-message) resolves the channel before it writes, so a failure there stores nothing. The request still returns 204 when a [Saved Message Create](/gateway/events/#saved-message-create), [Saved Message Delete](/gateway/events/#saved-message-delete), or [Recent Mention Delete](/gateway/events/#recent-mention-delete) Dispatch fails to publish after the write. -:::caution[The schedule is written before the Dispatch] -[Request bulk message deletion](#request-bulk-message-deletion) and [Cancel bulk message deletion](#cancel-bulk-message-deletion) publish [User Update](/gateway/events/#user-update) after the account row is written. A Gateway failure returns 502, 503, or 504 with the stored schedule already changed. +:::caution[A failed response can still change the schedule] +[Request bulk message deletion](#request-bulk-message-deletion) and [Cancel bulk message deletion](#cancel-bulk-message-deletion) can return 502, 503, or 504 after changing the schedule. Read the account again before deciding whether to retry. ::: ## Saved message object @@ -343,7 +343,7 @@ Neither scope includes the caller's personal notes channel, so this operation ca An account holding an MFA authenticator that proved sudo mode with MFA receives a fresh proof in the response header. A request that already had a valid proof gets that same token echoed back without an extended lifetime. -Fluxer queues the work with at most 5 attempts. As deletion progresses, each affected channel emits [Message Delete Bulk](/gateway/events/#message-delete-bulk) in batches of at most 100 message IDs, and the deleted messages' attachments are permanently removed. +As deletion progresses, each affected channel emits [Message Delete Bulk](/gateway/events/#message-delete-bulk) in batches of at most 100 message IDs. The deleted messages' attachments are permanently removed. On completion the system account sends the caller a direct message in the account locale reporting the total deleted message count and the number of channels touched. That message arrives through the ordinary [Message Create](/gateway/events/#message-create) Dispatch. @@ -382,12 +382,12 @@ The 204 has `X-Fluxer-Sudo-Mode-JWT` when sudo verification issued or reused a t ### Side effects -Any pending deletion for the account is removed from the queue first. The account then records a scheduled moment exactly one day in the future together with the number of messages and channels the deletion would affect. Those counts are computed once at scheduling time and are not recomputed while the deletion waits. +The new schedule replaces any pending deletion and starts exactly one day later. Its message and channel counts reflect the account at scheduling time and do not update while it waits. The updated counts and schedule are published to the caller through [User Update](/gateway/events/#user-update). No message is deleted by this request, so no channel receives a Dispatch until the scheduled work runs. :::caution[Nothing announces that the deletion finished] -When the scheduled work runs it clears the stored fields directly and emits no [User Update](/gateway/events/#user-update). +When deletion runs, the pending schedule becomes null without a [User Update](/gateway/events/#user-update). ::: ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/users/current-user.mdx b/fluxer_docs/src/content/docs/http-api/users/current-user.mdx index 1c5b2768f..dc699d4c2 100644 --- a/fluxer_docs/src/content/docs/http-api/users/current-user.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/current-user.mdx @@ -248,7 +248,7 @@ Schedules deletion of the current account and deletes every authentication sessi The body is the [sudo verification object](/http-api/users/mfa/#sudo-verification-object). It may be an empty object when the request presents an accepted sudo token. :::caution[Signing in before the deadline cancels the deletion] -The request records a deletion deadline set 336 hours ahead by default and marks the account self-deleted with the reason `USER_REQUESTED`. Erasure after the deadline is irreversible. +The deletion deadline is 14 days ahead by default. Once erasure starts, signing in cannot cancel it. ::: :::note[Transfer guild ownership first] @@ -261,6 +261,7 @@ Fluxer checks guild ownership before it accepts the deletion, so an owner of any | --- | --- | --- | | 204 | empty | Account deletion was scheduled | | 400 | [error response](/http-api/#error-response) | The caller owns at least one guild and the request returns `USER_OWNS_GUILDS` | +| 409 | [error response](/http-api/#error-response) | `CONFLICT`, because erasure has started or the deletion state changed during the request | ### Side effects diff --git a/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx b/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx index 7e9c87d9b..5e701be88 100644 --- a/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/data-harvest.mdx @@ -16,7 +16,7 @@ A completed harvest records a deadline exactly seven days after the archive was ## Harvest status values -The status is derived from the recorded timestamps in a fixed order, testing `failed_at` first, then `completed_at`, then `started_at`. +Use `status` to track preparation of the archive. | Value | Description | | --- | --- | @@ -94,7 +94,7 @@ A short English label describing the current stage. Every value is fixed except | Downloading attachments and creating archive | The ZIP archive is being written, with progress 65 | | Completed | The archive is ready, with progress 100 | -1 Skipped entirely when the harvest collected no message. Its progress is 5 plus the whole part of N divided by 200, capped at 55 +1 Skipped when no messages are collected ## Harvest download object @@ -182,7 +182,7 @@ A harvest record is created with a new [snowflake](/snowflakes/), the request ti Fluxer prepares the archive in the background. It contains: -- `user.json`, the account document +- `user.json`, the account document, including connections with their identifiers, visibility settings and verification dates but no credentials - one `channels/{channel_id}/messages.json` file for each channel that contributed a message, with its messages ordered oldest first - `payments/payment_history.json` - `integrations/oauth.json`, the [applications](/http-api/applications/) the account owns, with no entry for an application it only authorised @@ -191,7 +191,7 @@ Fluxer prepares the archive in the background. It contains: Attachment metadata appears with its message and has the attachment ID, filename, size, content type, CDN URL, and pixel dimensions. The attachment files themselves are never included. -Every authored message is collected, with no ceiling on the count. Fluxer skips a message it cannot read, and the harvest still succeeds. When the archive completes, Fluxer sends one email containing a download URL if the account has an email address and the instance has email delivery enabled. That URL expires seven days after it was issued. +Every authored message is collected, with no ceiling on the count. Messages that no longer exist are omitted. Other read failures fail the attempt. After completion, Fluxer attempts to email a download URL if the account has an email address and the instance has email delivery enabled. That URL expires seven days after it was issued. Email failure does not prevent downloading a completed archive. ### Rate limit @@ -234,7 +234,7 @@ The side effects match [Request data harvest](#request-data-harvest), except tha Returns the account's most recently created [harvest status](#harvest-status-object) object. -The most recent harvest is the one with the highest ID, and because IDs are [Snowflakes](/snowflakes/) that ordering is creation order. The result is the newest record whatever its state, so a completed archive is not preferred over a newer failed one. +Returns the newest harvest regardless of status, including a failed harvest newer than a completed one. :::note[An account with no harvest receives 200 and `null`] The body is the literal JSON value `null`, so decode it before treating the response as an object. @@ -316,7 +316,7 @@ The URL takes one of the forms below. When the instance has presigned harvest do Streams the archive of one completed harvest. -The signed `token` query parameter issued by [Get data harvest download URL](#get-data-harvest-download-url) is the whole authorisation, so the link stays usable from the harvest completion email and from a plain browser. It binds the account, the harvest ID, the stored object key, and an expiry. +The `token` query parameter issued by [Get data harvest download URL](#get-data-harvest-download-url) authorises the download without a session. Use the complete issued URL and keep it secret. The operation is active only while the instance has presigned harvest downloads disabled. That setting is enabled by default, so a default deployment answers every request here as 404 without inspecting the token. diff --git a/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx b/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx index 9c6273a53..b1af1cad1 100644 --- a/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/email-and-password.mdx @@ -557,7 +557,7 @@ No sudo verification and no email token step applies. ### Side effects -Fluxer replaces the account email, marks it verified, clears the bounced marker, and removes every email-clearable suspicious activity flag. That can empty `required_actions` and restore ordinary access. Fluxer marks the ticket completed and deletes its email token immediately. The contact change is recorded in the account's contact change log. +The account gets the verified replacement address, `email_bounced` becomes false, and satisfied email requirements are removed from `required_actions`. The ticket is completed and cannot be reused. No revert email is sent. @@ -678,9 +678,8 @@ Completing a password change deletes every authentication session for the accoun ### Side effects -The password and its change timestamp are replaced and the ticket is marked completed. Every outstanding password reset token is deleted. Fluxer deletes and ends every other authentication session on the Gateway first, then emits [Auth Session Change](/gateway/events/#auth-session-change), then deletes and ends the caller's own session. The returned replacement is the account's only session. OAuth2 access tokens and refresh tokens are not revoked. +The password is changed and the ticket is completed. All password reset tokens and existing authentication sessions are revoked. The caller receives [Auth Session Change](/gateway/events/#auth-session-change) with the replacement token before its session ends. The returned token is the account's only session. OAuth2 access tokens and refresh tokens remain valid. ### Rate limit 10 requests per minute for each authenticated user, on the `user:password_change:complete` bucket. - diff --git a/fluxer_docs/src/content/docs/http-api/users/gifts.mdx b/fluxer_docs/src/content/docs/http-api/users/gifts.mdx index 5f976eeb6..add542e18 100644 --- a/fluxer_docs/src/content/docs/http-api/users/gifts.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/gifts.mdx @@ -42,7 +42,7 @@ One gift code created by the current account, with its redemption state. A compl 3 A purchased code always has `1`, because the only gift products are one month and one year. The value `0` means a lifetime grant, and no current operation issues one -4 A code issued while repairing a lost checkout notification has the checkout time, so the value can predate the moment the code appeared +4 The checkout time, which can predate when the code appeared in the inventory 5 Always the current account diff --git a/fluxer_docs/src/content/docs/http-api/users/mfa.mdx b/fluxer_docs/src/content/docs/http-api/users/mfa.mdx index ba95b06c0..ba04ce13a 100644 --- a/fluxer_docs/src/content/docs/http-api/users/mfa.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/mfa.mdx @@ -50,7 +50,7 @@ An account that lost its saved backup codes reads the set back through an emaile [Sudo mode](#sudo-mode) applies to none of the four, so a user who no longer has the authenticator app still reaches the codes through the email address. The account needs a verified email address and TOTP enabled. Only [Start MFA backup codes challenge](#start-mfa-backup-codes-challenge) checks the address, and [Regenerate MFA backup codes](#regenerate-mfa-backup-codes) checks TOTP a second time. -A ticket is a version 4 UUID and is valid for 30 minutes from its last write. Starting the challenge, resending the code, and verifying the code each write the ticket. Regeneration reads it and writes nothing, so the window runs from the verification that preceded it. A ticket Fluxer no longer holds, or one opened by another account, fails with `INVALID_OR_EXPIRED_TICKET`. +A ticket is a version 4 UUID valid for 30 minutes after the challenge starts, the code is resent, or the code is verified. Regeneration does not extend it. An expired, unknown, or another account's ticket fails with `INVALID_OR_EXPIRED_TICKET`. A verification code is eight characters drawn from the uppercase Latin alphabet and the decimal digits, written as two four-character groups separated by a hyphen. A code is valid for 10 minutes from the moment it is sent, and each send replaces the code the previous send issued. @@ -58,7 +58,7 @@ A verification code is eight characters drawn from the uppercase Latin alphabet Fluxer trims the submitted value, uppercases it, and removes every hyphen before it compares. A wrong value fails with `INVALID_VERIFICATION_CODE` on the path `code`. ::: -Verification issues a proof, a version 4 UUID, and clears the code from the ticket. Fluxer compares a submitted proof in constant time and rejects a mismatch with `INVALID_PROOF_TOKEN`. A ticket holding no proof at all fails with `INVALID_OR_EXPIRED_TICKET` on the path `verification_proof`. +Verification consumes the code and issues a proof as a version 4 UUID. An incorrect proof fails with `INVALID_PROOF_TOKEN`. A ticket with no proof fails with `INVALID_OR_EXPIRED_TICKET` on `verification_proof`. Every ticket, code, and proof failure named here arrives as HTTP 400 whose top-level code is `INVALID_FORM_BODY`. The named value is the `code` of one [validation error](/http-api/#validation-error-object) entry, and that entry's `path` is the field it belongs to. @@ -77,7 +77,7 @@ Fluxer refuses a resend for 30 seconds after the previous send on the same ticke Sudo mode is a short-lived proof that the human in front of the session is still the account holder. An operation that requires it accepts a valid `X-Fluxer-Sudo-Mode-JWT` request header or the [sudo verification object](#sudo-verification-object) fields inside the JSON body. -The sudo token is an HS256 JSON Web Token with a lifetime of exactly five minutes. Its subject is the account [snowflake](/snowflakes/), and verification rejects a token whose subject is any other account. The token is opaque, and it is not bound to the session that obtained it. Fluxer generates one only when the account has at least one MFA authenticator and the request proved identity with MFA. +A sudo token lasts five minutes and works only for the account that obtained it, across that account's sessions. Treat it as opaque. A new token is issued only after an MFA proof from an account with an enrolled authenticator. :::caution[The echoed header repeats the request value] When Fluxer issues no fresh token it echoes the request's `X-Fluxer-Sudo-Mode-JWT` value back unchanged and never re-signs it, so the five-minute window never slides. Fluxer echoes the value even when it fails verification. @@ -200,7 +200,7 @@ The backup codes and the proof a verified challenge returns. ## WebAuthn credential object -An account holds at most 10 WebAuthn credentials. Fluxer checks the limit twice, once before [create WebAuthn registration options](#create-webauthn-registration-options) issues a challenge and again before [Register WebAuthn credential](#register-webauthn-credential) stores the result. It refuses parallel enrolment flows at whichever step first sees the tenth credential. +An account holds at most 10 WebAuthn credentials. Both [Create WebAuthn registration options](#create-webauthn-registration-options) and [Register WebAuthn credential](#register-webauthn-credential) enforce this limit. Fluxer verifies every assertion against the relying party identifier and the allowed origins configured for the instance. The credential's signature counter strictly increases, unless both the stored and reported counters are zero, which is how an authenticator without a counter appears. @@ -256,7 +256,7 @@ The browser WebAuthn `PublicKeyCredential` serialisation. Its field names are ca | clientExtensionResults | [WebAuthn client extension results](#webauthn-client-extension-results-object) object | Client extension outputs | | type | string | Credential type, always `public-key` | -The boundary does not validate the shape of this object, so no validation entry is ever reported against `webauthn_response` itself. Fluxer forwards it to WebAuthn verification exactly as supplied, and a malformed assertion fails that verification and surfaces as the `mfa_code` entry described in [sudo mode](#sudo-mode). +Malformed fields return `INVALID_FORM_BODY`. Each operation documents its cryptographic verification errors. ## WebAuthn assertion response object @@ -284,7 +284,7 @@ The boundary does not validate the shape of this object, so no validation entry | clientExtensionResults | [WebAuthn client extension results](#webauthn-client-extension-results-object) object | Client extension outputs | | type | string | Credential type, always `public-key` | -The boundary does not validate this shape either. An unusable attestation returns `INVALID_WEBAUTHN_CREDENTIAL`, and no validation entry names a field of this object. +Malformed fields return `INVALID_FORM_BODY`. An attestation that fails verification returns `INVALID_WEBAUTHN_CREDENTIAL`. ## WebAuthn attestation response object @@ -533,7 +533,7 @@ Neither a verified email nor an authenticator is required, so an account with no A backup code sent as `mfa_code` to satisfy sudo mode is consumed. With `regenerate` false it comes back with `consumed` set to true. With `regenerate` true it is deleted with the rest of the previous set and does not appear. :::caution[Regeneration replaces the previous set immediately] -Every code from the previous set is invalid as soon as an operation with `regenerate` set to true returns. The deletion and the insertion are separate writes, so the account holds no backup code at all between them. +Every previous code becomes invalid. If regeneration fails, the old codes may already be unusable. ::: ### JSON body @@ -671,7 +671,7 @@ The ticket and its proof are the only authorisation. Without TOTP enabled the re A ticket holding no proof returns `INVALID_OR_EXPIRED_TICKET` on the path `verification_proof`, and a proof that does not match returns `INVALID_PROOF_TOKEN`. :::caution[Regeneration leaves the ticket usable] -The operation reads the proof and writes nothing back to the ticket. The same pair replaces the set again on every call until the ticket expires, 30 minutes after its verification. +The same ticket and proof can replace the set again until the ticket expires, 30 minutes after verification. ::: ### JSON body @@ -690,7 +690,7 @@ The operation reads the proof and writes nothing back to the ticket. The same pa ### Side effects -Every code from the previous set is deleted and 10 replacements are written. The deletion and the insertion are separate writes, so the account holds no backup code at all between them. The authenticator types are unchanged and Fluxer emits no Gateway event. +The previous set is replaced with 10 new codes. A failure can leave the old codes unusable. Authenticator types are unchanged and no Gateway event is emitted. ### Rate limit @@ -899,4 +899,3 @@ Fluxer issues a sudo challenge for the current user. It expires after five minut ### Rate limit 10 requests per minute for each authenticated user, on the `sudo:webauthn:options` bucket. - diff --git a/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx b/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx index 51a67d1f3..c5c3268f1 100644 --- a/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/phone-verification.mdx @@ -23,8 +23,8 @@ An account satisfying none of them is refused with 403 `PHONE_ADD_NOT_ELIGIBLE`. Both operations run the check before they examine the submitted number. A refused account receives no message and consumes no challenge. [Start inbound phone challenge](#start-inbound-phone-challenge) skips the check entirely and issues a challenge for any non-bot session. -:::note[Eligibility reads the stored bitfield] -The [required actions](/http-api/users/#required-actions) array is filtered: empty for an account with no email address, empty for a contact address holding the required-actions exemption, and stripped of requirements the account already satisfies. An account with no visible required actions can still be eligible. +:::note[Visible required actions do not determine eligibility] +An account can qualify even when its [required actions](/http-api/users/#required-actions) array is empty. ::: ## Deferred phone requirement @@ -69,7 +69,7 @@ Keep phone numbers, verification codes, and challenge codes out of logs, analyti ## Provider lookup verdicts -[Verify phone code](#verify-phone-code) resolves the destination through the configured lookup provider before it checks the code. [Send phone verification](#send-phone-verification) does the same before it sends a code, except on an attempt that policy routes inbound before the lookup, where no lookup runs. A lookup result is cached for 7 days and reused for later attempts against the same number. The rules below run in order, and the first match decides. +Outbound SMS requires the number to pass the following checks. The first matching verdict applies. A number routed directly to an inbound challenge does not need this lookup. | Order | Condition | Verdict | | --- | --- | --- | @@ -243,8 +243,6 @@ On the 429, `X-RateLimit-Scope` reports `shared` when the per-number control or On an outbound result, Fluxer asks the configured SMS provider to deliver a one-time code. On an inbound result, it creates a one-use challenge for the current account. -Fluxer records an attempt against the phone attempt risk counters only once it reaches [provider lookup](#provider-lookup-verdicts). - ### Rate limit 5 requests per minute for each authenticated user, on the `phone:send_verification` bucket. diff --git a/fluxer_docs/src/content/docs/http-api/users/relationships.mdx b/fluxer_docs/src/content/docs/http-api/users/relationships.mdx index 7d799f4ed..018ca0d83 100644 --- a/fluxer_docs/src/content/docs/http-api/users/relationships.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/relationships.mdx @@ -6,7 +6,7 @@ description: Friendships, blocks, and pending friend requests in both directions import RouteHeader from '@/components/RouteHeader.astro'; -A relationship is the caller's stored record of one friendship, block, or pending friend request. Each account holds its own record, so one friendship is two records. The private note the caller keeps against another account lives on [User notes](/http-api/users/notes/). +A relationship describes a friendship, block, or pending friend request from the caller's perspective. Private notes belong to [User notes](/http-api/users/notes/). The routes here are user-only. A bot or OAuth2 bearer credential receives 403 `ACCESS_DENIED`, and an account with an outstanding required action receives 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. @@ -378,7 +378,7 @@ The body can be omitted, in which case it is treated as an empty object and the Each matched request removes the caller's `INCOMING_REQUEST` record and the sender's `OUTGOING_REQUEST` record, and emits [Relationship Remove](/gateway/events/#relationship-remove) to the caller and to the sender. A call that matches nothing changes nothing. -Fluxer processes senders one at a time, and the operation is not atomic across them. A failure part of the way through keeps the removals already made, and the call returns no count. +A failed request can leave some requests removed without returning a count. Read the relationships again to reconcile. ### Rate limit diff --git a/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md b/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md index 7830ad41f..1f03d6d5b 100644 --- a/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md +++ b/fluxer_docs/src/content/docs/http-api/users/settings-protobuf.md @@ -12,7 +12,7 @@ Decode the string before reading any preference, and encode a valid `SyncedPrefe The empty string in a response means nothing is stored. Sending null or the empty string clears it. A stored snapshot reaches the account's other sessions through [User Settings Update](/gateway/events/#user-settings-update). -Fluxer decodes every submitted snapshot and re-encodes it in canonical form before storing it, so a read can return a different string from the one submitted. Known fields are emitted in ascending field number order, and an unrecognised field number is preserved and re-emitted after them. Enums here are open, and an unassigned numeric value survives the round trip. When every known field holds its zero value and no unrecognised field is present, the snapshot encodes to zero bytes and is stored as the empty string. +A returned snapshot can have a different encoding from the submitted one. Unknown fields and enum values survive the round trip. A snapshot containing only default values is returned as the empty string. A submission may use either the standard or the URL-safe base64 alphabet, with or without padding. Fluxer always returns the standard alphabet with padding. @@ -39,7 +39,7 @@ An over-length string draws two entries for the one path. ## Synced preferences object -The `SyncedPreferences` message is the root of the snapshot. Every field is a preference group defined in its own section, except `sanitize_urls` and `save_camera_uploads_to_device`, which are bools. Field numbers are allocated in blocks: 1 to 3, 20 to 25, 40 to 45, 60 to 63, 80 to 82, and 100 to 113. Never derive a field number from a field's position in this table. +`SyncedPreferences` is the root message. Most fields are preference groups. Use the Protobuf schema for field numbers, not their position in this table. ### Structure @@ -173,7 +173,7 @@ The `accessibility` field has display, motion, message, media, voice, and intera 6 The complete CSS of the account's synced custom theme, stored inline. A client that has opted out of syncing its theme applies a local one instead and re-emits this value unchanged, so it does not overwrite the value on the devices that do sync -7 A multiplier, where 1 is unscaled. The first-party client keeps its zoom level in browser storage and does not read or write this field +7 A multiplier, where 1 is unscaled. Unused by the first-party client 8 A multiplier, where 1 is the unmodified speaking rate @@ -207,7 +207,7 @@ Field numbers 42 and 43 are reserved, together with the names `attachment_media_ ## Accessibility overrides object -The `accessibility_overrides` field holds dirty flags that no live surface reads or writes. The settings they name also appear in [accessibility settings](#accessibility-settings-object) as a `mobile_*_overridden` flag with a matching `mobile_*_value`. +The `accessibility_overrides` field is unused by the first-party client. Use the `mobile_*_overridden` and matching `mobile_*_value` fields in [accessibility settings](#accessibility-settings-object). ### Structure diff --git a/fluxer_docs/src/content/docs/http-api/users/settings.mdx b/fluxer_docs/src/content/docs/http-api/users/settings.mdx index 5dad49119..c7740f8dd 100644 --- a/fluxer_docs/src/content/docs/http-api/users/settings.mdx +++ b/fluxer_docs/src/content/docs/http-api/users/settings.mdx @@ -6,7 +6,7 @@ description: Account-wide settings, guild folders, notification settings, and vo import RouteHeader from '@/components/RouteHeader.astro'; -User settings are the stored preferences of one account. Fluxer keeps them in these records: the account-wide [user settings object](/http-api/users/#user-settings-object), and a [user guild settings object](#user-guild-settings-object) for each guild and for private channels. +User settings include account-wide [preferences](/http-api/users/#user-settings-object) and [notification settings](#user-guild-settings-object) for each guild and for private channels. Every route here is user-only and rejects a bot or OAuth2 bearer credential with 403 `ACCESS_DENIED`. Every route except [Get current user settings](#get-current-user-settings) also rejects an account with an outstanding required action, with 403 `ACCOUNT_SUSPICIOUS_ACTIVITY`. @@ -310,8 +310,8 @@ A custom status as submitted with a settings update. A custom emoji ID that does not resolve fails with `CUSTOM_EMOJI_NOT_FOUND` against `custom_status.emoji_id`. Fluxer drops the resolved emoji for an account without the global expression entitlement, so the request succeeds with text alone. -:::caution[The stored expiry is not swept] -An `expires_at` in the past does not clear the stored custom status and emits no Dispatch. The next Gateway session omits the expired status, and only an update naming `custom_status` replaces the stored record. Compare `expires_at` against the current time before rendering. +:::caution[Hide expired custom statuses] +An expired status can remain in settings without an expiry Dispatch. Compare `expires_at` with the current time before rendering. A new Gateway session omits the expired status. ::: ## Initial settings diff --git a/fluxer_docs/src/content/docs/http-api/webhooks.mdx b/fluxer_docs/src/content/docs/http-api/webhooks.mdx index 21ec0ce52..5d9bb3778 100644 --- a/fluxer_docs/src/content/docs/http-api/webhooks.mdx +++ b/fluxer_docs/src/content/docs/http-api/webhooks.mdx @@ -110,7 +110,7 @@ Every operation returning a [webhook object](#webhook-object) or a token webhook | message_reference?4 | ?[message reference input](/http-api/messages/#message-reference-input-object) object | Reply or forward reference, or null | | allowed_mentions?5 | ?[allowed mentions](/http-api/messages/#allowed-mentions-object) object | Mention parsing policy, or null | | flags? | integer | [Message flags](/http-api/messages/#message-flags), where omission is treated as zero and every bit outside the sendable mask is cleared | -| favorite_meme_id? | ?snowflake | Favorite meme to attach | +| favorite_meme_id? | ?snowflake | Favourite meme to attach | | sticker_ids? | ?array[snowflake] | At most 3 sticker IDs | | tts?6 | boolean | Text-to-speech request | | nonce?7 | string \| integer | Client-generated message identifier (1-32 characters). A non-negative safe integer becomes its decimal string | @@ -137,8 +137,6 @@ When `embeds` is absent, Fluxer rewrites the singular key `embed` to a one-eleme A multipart body accepts both [pre-uploaded attachments](/http-api/messages/#pre-uploaded-attachment-object) and [direct multipart attachment metadata](/http-api/messages/#direct-multipart-attachment-metadata-object). -Fluxer fetches an `avatar_url` through the media boundary. - ## Webhook attachment input object A JSON [webhook message body](#webhook-message-body) accepts this attachment metadata shape. Every member is optional, and a member outside this table is discarded. @@ -258,7 +256,7 @@ These objects define the body [Execute Slack webhook](#execute-slack-webhook) ac | value? | string | Field value, mapped to the embed field value | | short? | boolean | Whether the embed field is rendered inline (default false) | -:::caution[Converted embeds skip the rich embed schema] +:::caution[Slack embed fields have no individual length limits] No per-embed title, description, footer, or field maximum applies to a Slack value. ::: @@ -802,8 +800,6 @@ Creates a webhook in a guild text or voice channel and returns the new [webhook Creation emits a [Webhooks Update](/gateway/events/#webhooks-update). -Fluxer checks the request in a fixed order: the content filter, the rate limit, the credential, the body schema, channel access and `MANAGE_WEBHOOKS`, the guild allowance, the channel allowance, and the name scan. The avatar itself is checked last. - The guild allowance is the resolved [max_webhooks_per_guild](/http-api/instance/#limit-keys) value for the guild, defaulting to 1000. The channel allowance is the resolved [max_webhooks_per_channel](/http-api/instance/#limit-keys) value, defaulting to 15. ### Path parameters @@ -1306,7 +1302,7 @@ Any other type returns 400 `CANNOT_MODIFY_SYSTEM_WEBHOOK`, and a forward, which ### Side effects -The operation replaces the supplied fields, and a content change marks the message edited. An edit does not re-extract mentions, so the stored mention lists are kept and `allowed_mentions` is accepted and ignored. Supplying embeds replaces the complete embed collection and rechecks every attachment reference the embeds make. It emits [Message Update](/gateway/events/#message-update) to sessions that can read the channel. +The operation replaces the supplied fields, and a content change marks the message edited. Existing mentions stay unchanged and `allowed_mentions` is ignored. Supplying embeds replaces the complete collection and validates its attachment references. Sessions that can read the channel receive [Message Update](/gateway/events/#message-update). ### Rate limit @@ -1496,4 +1492,3 @@ Mention parsing is disabled for the created message, so no mention in a provider ### Rate limit 200 requests per minute for each caller identity and webhook ID, on the `webhook:instatus::webhook_id` bucket, which is exempt from the global limit. - diff --git a/fluxer_docs/src/content/docs/index.md b/fluxer_docs/src/content/docs/index.md index e7824c3ce..b186f2dde 100644 --- a/fluxer_docs/src/content/docs/index.md +++ b/fluxer_docs/src/content/docs/index.md @@ -1,63 +1,51 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Fluxer API -description: The Fluxer protocol surfaces and the contracts they share. +description: Start using the Fluxer API. --- -Fluxer is a self-hostable chat platform. Its API has the surfaces below, and they all share one identifier space. +Fluxer is a self-hostable chat platform. - To build a client or a bot, start with the [HTTP API](/http-api/) and the [Gateway](/gateway/overview/). -- For voice or a screen share, read [Voice](/voice/). +- For voice and screen sharing, read [Voice](/voice/). - To run an instance, start with [Get started](/operator/get-started/). -- To look up one route, use the sidebar or the [Protocol surfaces](#protocol-surfaces) table. ## Protocol surfaces -| Surface | What it is | Reference | -| --- | --- | --- | -| HTTP API | Resource reads and mutations below `/v1` | [HTTP API](/http-api/) | -| Gateway | A persistent WebSocket for session state and real-time events | [Gateway](/gateway/overview/) | -| Media Proxy | Attachments, image assets, themes, entrance sound audio, and the upload relay | [Media Proxy](/media-proxy/overview/) | -| Admin API | The privileged namespace below `/v1/admin` | [Admin API](/admin-api/) | - -A client mutates a resource over the HTTP API and receives the resulting update as a Gateway [Dispatch](/gateway/events/). Each operation states the Dispatches it fires, and [Events](/gateway/events/) defines each payload and its recipient scope. - -A [snowflake](/snowflakes/) is the identifier all surfaces share. Voice runs on LiveKit, and [Voice](/voice/) defines the placement protocol and the media transport. +Use the HTTP API to read and change resources, and Gateway [events](/gateway/events/) to receive updates. The [Media Proxy](/media-proxy/overview/) serves media and uploads. The [Admin API](/admin-api/) provides privileged instance management. ## Shared contracts -| Read this | For | -| --- | --- | -| [Conventions](/conventions/) | Wire table notation, footnotes, omission and `null` | -| [Authentication](/authentication/) | The `Authorization` grammar and the credential kinds | -| [Snowflakes](/snowflakes/) | Identifiers, ordering, and pagination cursors | -| [Errors](/http-api/errors/) | The error envelope and the code registries | -| [Rate limits](/topics/rate-limits/) | Buckets, the 429 body, and the `X-RateLimit-*` headers | -| [Locales](/topics/locales/) | The locale registry and `Accept-Language` negotiation | +See [Authentication](/authentication/), [Errors](/http-api/errors/), [Rate limits](/topics/rate-limits/) and [Locales](/topics/locales/) for shared behaviour. Most resource identifiers are [snowflakes](/snowflakes/). Each resource documents exceptions. + +## Field notation + +In field tables, `?` after a field name means optional. Before a type, it means nullable. + +| Field | Type | Meaning | +| --- | --- | --- | +| `name` | `string` | Required and cannot be `null` | +| `name?` | `string` | Optional, but cannot be `null` when present | +| `name` | `?string` | Required, but can be `null` | +| `name?` | `?string` | Nullish, so it can be omitted or set to `null` | + +An omitted field is different from a field set to `null`. Each operation explains how these values affect the resource. + +In the type column, `array[type]` is an array of the named type, and `map[key, value]` is a JSON object keyed by the first type with values of the second. ## Endpoint discovery -A client that knows only a Fluxer origin sends `GET /.well-known/fluxer` first. The route is unversioned, accepts no credential, and is readable from any origin. +Start with `GET /.well-known/fluxer` on the instance origin. It requires no authentication and allows cross-origin requests. ```text GET https://example.com/.well-known/fluxer ``` -The instance Fluxer hosts answers discovery at `https://fluxer.app/.well-known/fluxer`. That origin is the one thing a client is given. +For the hosted instance, use `https://fluxer.app/.well-known/fluxer`. -The response is the [instance discovery object](/http-api/instance/#instance-discovery-object). Every base URL a client uses comes from the [instance endpoints object](/http-api/instance/#instance-endpoints-object) inside it. A client MUST read every base URL from that response, and it MUST NOT derive one from the origin it was given or assume an official Fluxer domain. +Read base URLs from the response's [endpoints](/http-api/instance/#instance-endpoints-object). Do not derive them from the origin or hard-code Fluxer domains. -The base URL a client takes depends on its kind. +- Bots, libraries and third-party clients use `endpoints.api_public`. +- The first-party web application uses `endpoints.api_client`. `endpoints.api` is an alias for it. -- `endpoints.api_public` is the endpoint a bot, a library, or any other third-party client uses. -- `endpoints.api_client` is the endpoint the first-party web application uses. -- `endpoints.api` repeats `endpoints.api_client`. - -A credential goes in the `Authorization` header. - -```text -GET https://api.example.com/v1/users/@me -Authorization: flx_ZDb1GURItsMuYl1zvrgxv2qLBxyNmgNSEaWT -``` - -That credential is a user session token. [Log in with a password](/http-api/authentication/#log-in-with-a-password) issues one. A bot sends a bot token with the `Bot` prefix, issued by [Create application](/http-api/applications/#create-application). [Authentication](/authentication/) gives the exact form of each kind. +Use `/v1` paths and send credentials in the `Authorization` header as described in [Authentication](/authentication/). diff --git a/fluxer_docs/src/content/docs/media-proxy/overview.md b/fluxer_docs/src/content/docs/media-proxy/overview.md index 4bd96abc2..20fcc8994 100644 --- a/fluxer_docs/src/content/docs/media-proxy/overview.md +++ b/fluxer_docs/src/content/docs/media-proxy/overview.md @@ -63,11 +63,7 @@ Every read route accepts `GET` and `HEAD`. HEAD returns the same status and repr The relay path accepts `PUT`. Any other method there returns 405 with an `Allow` header. An unknown path returns 404. -The [signed external route](/media-proxy/routes/#get-signed-external-media) is the one place a HEAD can answer differently from the matching GET. A HEAD with no range and no transformation is served from an origin HEAD when that origin returns 200, declares a length within the [500 MiB media bound](/media-proxy/responses-and-limits/#request-and-media-limits), and names a non-SVG media type. - -:::note[An origin HEAD resolves the type without bytes] -That answer uses the declared type and the filename alone, so an origin that mislabels its bytes produces a different `Content-Type` and `Content-Disposition` than the GET of the same URL. -::: +On the [signed external route](/media-proxy/routes/#get-signed-external-media), origin metadata can cause HEAD representation headers to differ from GET. ## Request headers @@ -86,7 +82,7 @@ Only `Range` affects the representation a public read returns. `X-Forwarded-For` ## Access restrictions -An operator MAY gate public reads on a CDN edge address allowlist. The gate is disabled by default. When it is enabled, the process fetches the Bunny edge address list at startup and refreshes it every 3600 seconds by default, and a process whose first fetch fails does not start. +An operator can restrict public reads to a CDN edge address allowlist. This restriction is disabled by default. A request from an address outside the list returns 403. `/_health`, `/_metrics`, `/_metadata`, `/_thumbnail`, `/_frames`, and every path below `/v1/relay/` are exempt. @@ -98,7 +94,7 @@ A read route names its representation in its query string. The attachment, signe Query names and values use URL form decoding, so `+` decodes to a space and a percent escape decodes to its byte. When a name occurs more than once, the final value wins. An unknown name is ignored. -The Media Proxy canonicalises nothing and issues no redirect for a noncanonical target. Two spellings of the same selection are two separate cache entries. +The Media Proxy does not redirect alternative spellings to a canonical URL. A Boolean is true only for case-insensitive `true` or the exact value `1`. Every other value, including `false` and `0`, is false. [Transformations](/media-proxy/transformations/) defines dimensions, formats, quality values, and animation flags. @@ -118,14 +114,10 @@ A reversed range, a zero-length suffix, a start outside the representation, or a The route forwards a range to the origin only when no transformation is requested. It sends the range verbatim when the value after `bytes=` is non-empty and every byte of it is an ASCII graphic character. A multiple range therefore reaches the origin, and the origin decides how to answer it. A value with a space anywhere is dropped, and no range is sent. The route relays the origin partial response with the origin `Content-Range` unchanged. -A transforming request forwards no range to the origin, and the client range applies to the transformed bytes, so it still returns 206 or 416. On a non-transforming request, an origin 200 is relayed as that 200 when its declared type is trustworthy and the response is not SVG by declared type, filename, or leading bytes. The relayed 200 does not reapply the client range. Fluxer rasterises an SVG response and applies the client range to the rasterised bytes. - -A trustworthy type is a normalised `image/`, `video/`, or `audio/` type other than `application/octet-stream`. An absent or empty `Content-Type`, `text/plain`, `application/pdf`, and `application/zip` are all untrustworthy. Fluxer buffers the body of a 200 under an untrustworthy type and applies the client range to those bytes, so that read returns 206. - -The route fetches twice in exactly one case. When an origin answers a forwarded range with 206 under a declared SVG media type, Fluxer discards that partial response and fetches the whole object again without a range. It rasterises the object and applies the client range to the rasterised bytes. +A range on a transformed response selects bytes from the result and returns 206 or 416. Without a transformation, an origin that ignores the range can produce a complete 200 response. Always check the response status and range headers. :::caution[A mislabelled SVG reaches the client as bytes] -The re-fetch tests the declared media type alone. An origin that answers a forwarded range with SVG bytes under another type produces a 206 of raw SVG under that type. +An origin that answers a forwarded range with SVG bytes under another media type can produce a 206 containing raw SVG. ::: Disposition follows that declared type, so SVG mislabelled as an image or video media type is served inline. @@ -171,19 +163,13 @@ A 416 response has `Accept-Ranges`, `Content-Range`, `Access-Control-Allow-Origi ## Content detection -For a streamed object, Fluxer first reads stored media type metadata that names an image, audio, or video. Otherwise it takes the filename extension, then any other non-empty declared type except `application/octet-stream`, and finally `application/octet-stream`. - -When the complete input is available, detected SVG takes precedence. Another trustworthy declared image, audio, or video type remains authoritative. Without one, Fluxer checks the leading 8,192 bytes, then the filename extension, then the declared type, and finally uses `application/octet-stream`. - -A filename extension that identifies MP4 audio overrides a declared `video/mp4` with `audio/mp4`. +Use the response's `Content-Type`. It can differ from the filename extension or the origin's declared type. ## Content disposition Disposition follows the resolved media type. An image other than SVG and a video are served inline. Every other type, including SVG and PDF, is served as an attachment. An explicit `download` request forces attachment disposition on every route that accepts the parameter. -The disposition filename comes from the route. An attachment or signed external read uses the filename in the path or target URL. An image asset uses the path hash with any `a_` prefix stripped, followed by the canonical name of the path extension, so `/avatars/1/a_abcd1234.jpg` is offered as `abcd1234.jpeg`. - -When `download` resolves to true and the served media type has a canonical extension the filename does not already use, the filename keeps its stem and takes that extension. A PNG transformation of `holiday.jpg` is offered as `holiday.png`. When a filename is not safe as a quoted ASCII value, Fluxer sends a sanitised quoted fallback and an RFC 5987 `filename*` parameter. +Use the filename from `Content-Disposition` when saving a response. Transformations can change its extension, and non-ASCII filenames can use the `filename*` parameter. :::caution[A scriptable document is never inline] The attachment, image asset, and signed external routes rasterise SVG to WebP, so a browser does not execute the document in the Media Proxy origin. diff --git a/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md b/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md index e9f08619a..503f8193a 100644 --- a/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md +++ b/fluxer_docs/src/content/docs/media-proxy/responses-and-limits.md @@ -82,32 +82,17 @@ The third-party origin chose the status, and Fluxer passed it through. Proxied or stored media is limited to 500 MiB, and exceeding that returns 413. The bound applies to a streamed object, a buffered object, an external response body, and any input selected for transformation. When a streamed external body passes the bound only after the response head is committed, the Media Proxy truncates it. -A decoded signed external target URL is limited to 8,192 bytes, and a longer URL returns 400. The route follows at most five redirects. A sixth redirect returns 502, and so does a redirect back to an already visited URL. Every redirect target is subject to the same bound and the same address policy as the original URL. Content detection inspects the leading 8,192 bytes of a body. - -Buffered external bodies share one endpoint budget of 500 MiB for every [work admission](#work-admission) slot plus 512 KiB. A body the budget cannot cover returns 503, and so does a failed buffer allocation. +A decoded signed external target URL is limited to 8,192 bytes, and a longer URL returns 400. The route follows at most five redirects. A sixth redirect or a redirect loop returns 502. Every redirect target is subject to the same URL limit and address policy. Decoded images are limited to 16,384 pixels on either edge and 268,435,456 pixels in total. Animated input is limited to 20,000 frames and 1,073,741,824 decoded pixels across all frames. No configuration changes these bounds. [Transformations](/media-proxy/transformations/#transformation-limits) defines the resulting failure statuses. -The upload relay limits a body to the smaller of the capability's declared maximum and the endpoint's configured body limit. That endpoint limit defaults to the same 500 MiB ceiling and can be configured from 1 byte through 5 GiB. A request that declares no `Content-Length` is spooled to disk first, and spooled bodies share an 8 GiB endpoint budget by default. +The upload relay limits a body to the smaller of the authorised upload size and the endpoint body limit. That endpoint limit defaults to 500 MiB and can be configured from 1 byte through 5 GiB. An internal `/_metadata`, `/_thumbnail`, or `/_frames` request body is limited to the base64 expansion of the 500 MiB media bound plus 1 MiB. All answer a larger body with 413. ## Work admission -A transformation first takes an admission slot without waiting. The pool holds one slot for every concurrent native transform plus one for every queued transform. When no slot is free the request returns 504 immediately. - -| Setting | Default | Configurable range | -| --- | --- | --- | -| Native transform concurrency | The process parallelism clamped to 2 through 8 | 1 through 128 | -| Queue depth | Eight times the native transform concurrency | 1 through 8192 | - -Once admitted, the transformation waits for a native transform permit until the transformation deadline. A wait that outlives the deadline also returns 504. - -The image branch of `/_thumbnail` follows the same admission and deadline rules. Its video branch takes no admission slot and no permit at all. - -A successful transformation stays in the memory cache for 120,000 ms by default, within a 256 MiB total budget and a 64 MiB per-entry budget1. External content-type hints are cached for the same interval across at most 4,096 targets. - -1 The per-entry budget is clamped to the total budget, and setting the interval, the total budget, or the per-entry budget to zero disables the transform cache +A transformation returns 504 when capacity is unavailable or its deadline expires. Upload and external-media requests can return 503 when their capacity is exhausted. ## Deadlines @@ -123,7 +108,7 @@ A successful transformation stays in the memory cache for 120,000 ms by default, The same socket timeout bounds every streamed response body. A streamed stored object and a streamed signed external response terminate when the gap between two body chunks exceeds that timeout. The whole transfer has a second deadline of that timeout plus one second for every 16 KiB of expected length, which is a floor of 16 KiB per second. A body that ends before, or runs past, the advertised `Content-Length` also terminates with an error. -Animated encoding stops adding frames 3,000 ms before the transformation deadline, so the encoder has time to flush what it already holds. The request then succeeds with a shorter animation. The separate 30,000 ms animation bound caps the playback length of the encoded animation, and [Transformations](/media-proxy/transformations/#transformation-limits) defines it. +An animated response can be shortened to meet its deadline or playback limit. See [Transformation limits](/media-proxy/transformations/#transformation-limits). :::caution[A deadline after the head truncates the body] A status and its headers are chosen before the body is sent. A streamed object store or external response that fails afterwards terminates the body, so the observed body can be shorter than the advertised `Content-Length`. diff --git a/fluxer_docs/src/content/docs/media-proxy/routes.mdx b/fluxer_docs/src/content/docs/media-proxy/routes.mdx index cf4c52e57..2a3bdbc75 100644 --- a/fluxer_docs/src/content/docs/media-proxy/routes.mdx +++ b/fluxer_docs/src/content/docs/media-proxy/routes.mdx @@ -60,7 +60,7 @@ The path has no signature and no expiry, so anyone holding the URL reads the obj [When a transformation runs](/media-proxy/transformations/#when-a-transformation-runs) defines the trigger. -Without a transformation Fluxer streams the object from the store and forwards the range to it. With one it reads the complete object into memory first, and the range applies to the transformed bytes. +When a transformation is requested, `Range` selects bytes from the transformed result. An SVG attachment with a transformation parameter is rasterised, and `format` and `quality` select the output as they do for any other source, defaulting to WebP. Without a transformation parameter, only an `mp` endpoint rasterises it, always as lossless WebP. An `upload` endpoint returns the stored SVG bytes. @@ -83,7 +83,7 @@ A video source requires an explicit image `format`. Another transformation param -Returns external media through Fluxer, optionally transformed. Fluxer verifies the signature before it decodes or fetches the target. +Returns external media through Fluxer, optionally transformed. Use the signed URL supplied by the HTTP API. Fluxer constructs the signed path and exposes it on these fields: @@ -99,10 +99,8 @@ A field holds a signed path only when its source URL is external. A URL already | Field | Type | Description | | --- | --- | --- | -| signature | string | The unpadded base64url HMAC-SHA256 of the target component under the deployment secret | -| target1 | string | The encoded target component, which can contain `/` separators | - -1 The opaque form is `v2/` followed by the unpadded base64url of the complete URL. The segmented form joins an optional percent-encoded `?query` segment, the scheme, the host and port, and the percent-encoded path segments with `/` +| signature | string | The signature from the issued URL | +| target | string | The opaque target from the issued URL | A client MUST preserve both components exactly as issued and MUST NOT decode or reconstruct either one. The signature covers the target component alone, so adding or changing a query parameter does not invalidate it. A path with no `/` after `/external/` returns 400. @@ -114,7 +112,7 @@ Anyone holding a signed path fetches that external target through Fluxer for as Fluxer validates the decoded target before every request and again for every redirect hop. It must be at most 8,192 bytes, must use `http` or `https`, must have a non-zero port when it names one, and must not contain credentials or a control character. A fragment is dropped before the fetch. -The target must resolve to a public address. A hostname must be at most 253 bytes and must contain at least one dot. Its labels use only ASCII letters, digits, and `-`, run to at most 63 bytes, and begin and end alphanumerically. The final label is not digits only. Fluxer rejects a literal address in an unspecified, loopback, private, link-local, carrier-grade NAT, benchmarking, documentation, 6to4 relay, multicast, reserved, broadcast, unique-local, IPv4-mapped, 6to4, or NAT64-embedded range. Fluxer validates every address DNS returns the same way before it opens the connection. +The target must resolve to a public internet address. Private, local and reserved addresses are rejected, including through redirects. A hostname whose lookup fails or resolves to no address returns 400. @@ -135,11 +133,9 @@ A redirect beyond the [five-redirect bound](/media-proxy/responses-and-limits/#r A transformation begins when `width`, `height`, `format`, or `quality` is present, when `animated` resolves to true, when the target filename ends in `.svg`, or when the origin body is SVG by media type or by its first bytes. A transforming request forwards no `Range` to the origin and applies the client range to the transformed bytes, so it still selects 206 or 416. -A non-transforming request forwards the client range to the origin under the filter [byte ranges](/media-proxy/overview/#byte-ranges) defines, and relays an origin 206 with its `Content-Range` and `Content-Length` unchanged. An origin that ignores the range answers 200, and Fluxer streams that whole body through when the origin names an `image/`, `video/`, or `audio/` media type other than `image/svg+xml`. Fluxer buffers a transforming request, an SVG body, and any body whose declared media type is empty, `application/octet-stream`, or outside those categories. A buffered response has the range applied to the bytes the route finally serves, so a client range over a buffered origin 200 produces 206. +A non-transforming request can forward the range to the origin. Handle both 200 and 206 responses and use their range headers, as described in [Byte ranges](/media-proxy/overview/#byte-ranges). -A body the [external buffer budget](/media-proxy/responses-and-limits/#request-and-media-limits) cannot cover returns 503. - -This is the one route whose `HEAD` can answer differently from its `GET`. A `HEAD` with no transformation and no range is served from an origin `HEAD` when that origin returns 200, declares a `Content-Length` of at most 500 MiB, and names a non-SVG media type. Any other `HEAD` runs the `GET` path and returns its headers with an empty body. [Methods](/media-proxy/overview/#methods) defines how that origin `HEAD` resolves the media type. +Insufficient capacity to process the body returns 503. Origin metadata can make `HEAD` representation headers differ from `GET`. A video target requires an explicit image `format` to produce a thumbnail. Without one, Fluxer returns the original bytes unchanged, and it does the same for a target that is neither an image nor a video. @@ -154,7 +150,7 @@ A video target requires an explicit image `format` to produce a thumbnail. Witho | 413 | Payload too large | The origin declared or delivered more than 500 MiB | | 416 | empty | A local range is unsatisfiable | | 502 | Bad gateway | The origin could not be reached, exceeded the redirect bound, or returned a status outside the retained set | -| 503 | Service unavailable | The external buffer budget cannot cover the body, or the buffer allocation failed | +| 503 | Service unavailable | Insufficient capacity to process the body | | 504 | Gateway timeout | Transformation capacity was unavailable or the transformation exceeded its deadline | 1 A forwarded range relays the origin `Content-Range` and `Content-Length` unchanged and omits either header the origin did not send, so a chunked origin 206 produces a 206 with no `Content-Length`. A local range over buffered or transformed bytes always has both @@ -207,7 +203,7 @@ Returns the stored audio of a user entrance sound. The [entrance sounds resource 1 A non-empty run of ASCII digits. Any other value makes the path unroutable and returns 404 -2 The hash is non-empty and ASCII alphanumeric, and Fluxer issues the first 16 hexadecimal characters of the MD5 digest of the audio. The extension is the exact lowercase `mp3`, `ogg`, `m4a`, or `wav` +2 Use the hash returned by the API. The extension is the exact lowercase `mp3`, `ogg`, `m4a`, or `wav` The route selects no representation, accepts a `Range`, and sends no `Content-Disposition`. A successful response streams the stored bytes with the detected audio media type and the one-year `no-transform` cache policy. @@ -233,9 +229,7 @@ An attachment has its own `url` and `proxy_url`, a signed external media path ha Fluxer matches an asset path by its shape alone. A path with the wrong number of segments, an empty owner segment, or a filename without exactly one dot is not an asset path and returns 404. A hash containing anything but ASCII letters, digits, and `_` returns 404 as well, as does an extension outside the known image set. The known set is `png`, `jpg`, `jpeg`, `webp`, `gif`, `apng`, `avif`, `heic`, `heif`, `jxl`, and `svg`, matched case-insensitively. -Fluxer issues an eight-character lowercase hexadecimal hash, taken from the MD5 digest of the submitted image before metadata stripping. An owner segment is any non-empty segment, and Fluxer never checks it against a snowflake. - -The storage key drops an `a_` prefix from the hash, except on an emoji or sticker path, whose key keeps the filename stem exactly as given. A path whose key is missing is also tried under the canonical name of its extension, so both `.jpg` and `.JPG` resolve an object stored as `.jpeg`. +Use the hash returned by the API. Do not calculate one from the image yourself. An owner segment of the literal `.` or `..` parses as an asset path but produces an unsafe storage key and returns 400. So does a hash of the literal `a_` on a path that strips the prefix. @@ -438,7 +432,7 @@ Returns the emoji image under the emoji size class with a square cover crop. 2 An extension whose encoder is not enabled for output selects WebP instead -Fluxer issues emoji paths without an `a_` prefix, so an emoji request defaults to static output and needs `animated=true` for animation. The parser still reads a leading `a_` as an animation request and keeps the stem verbatim in the storage key, so `/emojis/a_123.webp` names a different object than `/emojis/123.webp`. +Emoji requests default to static output. Use `animated=true` for animation, without adding an `a_` prefix to the identifier. ### Response @@ -459,7 +453,7 @@ Returns the sticker image under the sticker size class with a square cover crop. 1 Any non-empty run of ASCII letters, digits, and `_`. Fluxer clients request `{sticker_id}.webp` -Fluxer issues sticker paths without an `a_` prefix, so a sticker request defaults to static output and needs `animated=true` for animation. The parser still reads a leading `a_` as an animation request and keeps the stem verbatim in the storage key. +Sticker requests default to static output. Use `animated=true` for animation, without adding an `a_` prefix to the identifier. ### Response diff --git a/fluxer_docs/src/content/docs/media-proxy/transformations.md b/fluxer_docs/src/content/docs/media-proxy/transformations.md index d5085f004..ac03ffe98 100644 --- a/fluxer_docs/src/content/docs/media-proxy/transformations.md +++ b/fluxer_docs/src/content/docs/media-proxy/transformations.md @@ -60,7 +60,7 @@ Transformations never enlarge an image. A cover crop scales a still image to cover the requested rectangle and crops it centrally. It applies to an attachment or signed external request that supplies both `width` and `height`, and to every emoji or sticker asset. Every other image asset fits inside the selected square and preserves its full aspect ratio. :::note[An animated transformation fits the whole frame] -The Media Proxy downgrades a cover crop to a plain fit whenever it opens the decoder for every page. An animated emoji or sticker is fitted inside its square. +Animated output fits inside the requested bounds without cropping, including animated emoji and stickers. ::: ## Asset size selection @@ -120,33 +120,33 @@ Quality names are matched exactly and are case-sensitive. An unrecognised value 1 PNG, APNG, and GIF have fixed encoder settings and ignore `quality`, so the quality number reaches WebP and JPEG output only -2 Resolves to `lossless` only for animated WebP output whose source sniffs as GIF or APNG, is at most 4,194,304 bytes, and decodes to at most 16,777,216 pixels across all frames. It resolves to `high` otherwise, and an animated palette WebP that fails those tests uses WebP encoder effort 0 unless an attachment request supplies `effort` +2 Selects quality automatically based on the source 3 Lossless applies to WebP alone, and JPEG at quality 100 is still a lossy encode An image asset defaults to `high`. An attachment or signed external image defaults to `lossless`, except that a JPEG, HEIC, or HEIF source defaults to `high`. Animated WebP output defaults to `auto` on every route that reads `quality`. Fluxer extracts a video thumbnail at `high`, and `quality` then applies only to the resize step that `width` or `height` requests. A non-transforming SVG rasterisation always uses `lossless`. -Encoder effort defaults to 2 for animated output or `low` quality and 4 otherwise, and it applies to WebP output only. JPEG and PNG have fixed encoder settings, and GIF always encodes at effort 7. The attachment-only `effort` parameter replaces the default and is clamped to 9. Static WebP output clamps it again to 6, and so does lossy animated WebP. Only lossless animated WebP uses 7 through 9. +The attachment-only `effort` parameter controls WebP encoding effort. Values above the selected encoder's maximum are clamped. Other output formats ignore it. ## Animation -An owner-and-hash asset whose hash begins with `a_` requests animated output by default, and a bare hash requests static output by default. The prefix is stripped from the storage key, so both spellings read the same stored object. +An owner-and-hash asset whose hash begins with `a_` requests animated output by default. A bare hash requests static output. The `animated` parameter overrides that default in both directions. It is read whenever the name is present, so `animated=false` forces static output even for an `a_` hash. Omitting the name keeps the route default. Fluxer issues emoji and sticker paths without an `a_` prefix, so those requests default to static output and need `animated=true` for animation. An attachment or signed external request also defaults to static output. -When encoding occurs, animation survives only when `animated` resolves to true and the selected output is WebP, GIF, or APNG. The Media Proxy rejects the original bytes when the stored bytes sniff as animated and the request resolves to static, so that request is re-encoded to a single frame. Only a read that selects no transformation returns the source animation without `animated=true`. +For transformed output, animation requires an animated request and WebP, GIF or APNG output. Static output contains one frame. A read that selects no transformation preserves the source animation. :::caution[PNG and JPEG output stacks the frames] -PNG and JPEG have no animation, but `animated=true` still opens every page of the source. The encoder receives the frames stacked vertically and writes one tall image. +PNG and JPEG cannot represent animation. Requesting them with `animated=true` can produce one tall image with the frames stacked vertically. ::: An animated GIF request for GIF output is resized in its original container, and is returned untouched when the requested size would not change it. Requesting a cover crop routes it through the image pipeline, which still emits GIF. ## Video thumbnails -Fluxer extracts one thumbnail from a video only when the request supplies an explicit image `format`. The video itself is never transcoded. The extractor scans at most 512 packets to find a frame and encodes it as JPEG, PNG, WebP, GIF, or APNG. A `format` value with no output encoder has already been coerced to WebP, so no other target reaches the extractor. `width` and `height` fit the thumbnail inside the requested rectangle without cropping. +Supply an image `format` to extract a video thumbnail. The video itself is never transcoded. `width` and `height` fit the thumbnail inside the requested rectangle without cropping. An attachment video request with another transformation parameter but no `format` returns 400. A signed external video request without `format` returns the original bytes instead. @@ -158,7 +158,7 @@ The Media Proxy returns the original bytes when the source already has the selec An `effort` value forces encoding, and a `quality` value forces encoding for every source except GIF. With neither `width` nor `height`, an animated attachment or signed external request for the source's own GIF, WebP, or APNG format bypasses both tests and can still reuse the original animation. -The response `Content-Type` comes from the content when the stored media type is empty, is case-insensitively `application/octet-stream`, or is outside the set `image/jpeg`, `image/png`, `image/webp`, `image/gif`, `image/apng`, `image/avif`, `image/heic`, `image/heif`, `image/jxl`, and `image/svg+xml`. An original response can therefore use a different media type from the stored metadata. A stored media type from that set is trusted even when it disagrees with the bytes and is served unchanged. +Use the response `Content-Type`, which can differ from the filename extension or stored metadata. ## Transformation limits @@ -168,7 +168,7 @@ Decoded images are limited to 16,384 pixels on either edge and 268,435,456 pixel Animated WebP and animated APNG output is bounded again at encode time, and exceeding one of those bounds truncates the output. The encoder stops adding frames after 20,000 frames, or once the accumulated frame delays reach 30,000 ms of playback, and emits the frames it already has. An operator can configure the frame cap from 1 through 100,000 and the playback cap from 100 through 600,000 ms. Animated GIF output has neither cap on either of its paths, so the decode limits above are its only bound. -The overall transformation deadline defaults to 15,000 ms and can be configured from 1,000 through 120,000 ms. An animated WebP or APNG encode stops 3,000 ms before that deadline so it has time to flush what it has already encoded. Animated GIF output has no such headroom, and a GIF resize that reaches the deadline fails. +The transformation deadline defaults to 15,000 ms and can be configured from 1,000 through 120,000 ms. WebP or APNG animation can be shortened to meet it. A GIF resize that reaches the deadline fails. An attachment or signed external transformation failure returns 400. An image asset transformation failure returns 500 when the source is not directly displayable, and otherwise returns 200 with the original stored bytes and no `Content-Disposition`. diff --git a/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx b/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx index c81b2e74f..ccfaef2de 100644 --- a/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx +++ b/fluxer_docs/src/content/docs/media-proxy/upload-relay.mdx @@ -15,7 +15,7 @@ Paths here are relative to the relay base in the issued upload URL. That base is The capability is the complete authorisation. The relay reads no HTTP API `Authorization` header and has no request-count rate limit. Every path below `/v1/relay/` is exempt from the [media access policy](/media-proxy/overview/#access-restrictions). The `PUT` has a [deployment mode](/media-proxy/overview/#deployment-modes) gate and returns 404 outside `upload` mode. :::note[Some upload URLs address object storage directly] -The HTTP API decides per request whether to relay, from the caller's country. A caller on the instance's direct-upload list receives a presigned object storage URL, and this contract does not describe it. +An upload URL may point directly to object storage. Use the URL returned by the HTTP API. This page describes relay URLs only. ::: :::caution[The complete URL is authenticated] @@ -24,57 +24,13 @@ The path key and the `t` capability are bound together by the signature. A clien ## Relay capability object -A relay capability is a JSON payload signed with HMAC-SHA256 under the deployment relay secret. The signed value is the encoded payload text, and the capability is that text, a `.`, and the signature. Both halves use unpadded base64url. - -The payload is opaque to a client. - -### Structure - -| Field | Type | Description | -| --- | --- | --- | -| b1 | string | The destination bucket | -| k2 | string | The destination object key | -| m3 | string | The authorised method, always `put` | -| u?4 | string | The multipart upload identifier | -| p?4 | integer | The multipart part number | -| ct?5 | string | The media type stored with the object | -| mb6 | integer | The maximum body length in bytes | -| e7 | integer | The expiry as a Unix timestamp in seconds | - -1 The uploads bucket configured on the endpoint. Any other value returns 403 - -2 Equal to the decoded request key. Any other value returns 403 - -3 A payload naming another method returns 401 - -4 Present together on a multipart part capability and absent together on a single-object capability. The relay checks the query string values against these fields and then forwards both to the object store - -5 Takes precedence over the request `Content-Type`. When neither declares one, an S3 backend stores the object as `application/octet-stream` - -6 The HTTP API sets it to the exact length of the object or part for an attachment, and to 1,000,000 for a stream preview - -7 The bound is exclusive, so the relay rejects the capability once the clock reaches it. The HTTP API issues a 900 second lifetime by default - -### Example - -```json -{ - "b": "fluxer-uploads", - "k": "a3f1c8de-4b21-4f0e-9c77-2d5b6e1a0f43", - "m": "put", - "ct": "image/png", - "mb": 184320, - "e": 1780000000 -} -``` - -The relay answers 401 to a malformed capability, a signature that is not 32 bytes, an incorrect signature, a payload that is not valid unpadded base64url, a payload that is not valid JSON, and an expired capability. +The `t` query parameter is an opaque bearer token. Keep it private and use it only with the issued URL. Upload URLs expire after 900 seconds by default. A missing, invalid or expired token returns 401. ## Put relay object -Stores the exact object the [relay capability](#relay-capability-object) authorises and returns 200 with an empty body. The response has an `ETag` when the object store supplied one. An S3 backend supplies one and a local filesystem backend does not. +Uploads the object or part authorised by the [relay capability](#relay-capability-object). Returns 200 with an empty body and an optional `ETag` header. ### Path parameters @@ -82,23 +38,21 @@ Stores the exact object the [relay capability](#relay-capability-object) authori | --- | --- | --- | | key1 | string | The object key of the issued URL | -1 The key can contain `/` separators and the issuer percent-encodes each segment. The relay compares the decoded key against the capability `k` field, and any other value returns 403 - -Fluxer issues a UUID for an attachment and `stream_previews/{name}-{digest}.jpg` for a stream preview. +1 Use the key exactly as it appears in the issued URL. Changing it returns 403 ### Query parameters | Field | Type | Description | | --- | --- | --- | -| t1 | string | The capability, as the unpadded base64url payload, a `.`, and the unpadded base64url signature | +| t1 | string | The opaque upload token | | uploadId?2 | string | The multipart upload identifier, present only when the capability has one | | partNumber?3 | integer | The multipart part number, present only when the capability has one | 1 A missing `t` returns 401 -2 When the capability has `u`, the relay rejects an absent or differing value with 403. When it has no `u`, the relay rejects a non-empty value with 403 and accepts an absent or empty one +2 Use the value from the issued URL. A missing or mismatched multipart upload identifier returns 403 -3 An empty or unparsable value returns 400. When the capability has `p`, the relay rejects an absent or differing value with 403, and when it has no `p`, the relay rejects any value with 403 +3 An empty or unparsable value returns 400. A part number that does not match the issued URL returns 403 ### Request headers @@ -107,9 +61,9 @@ Fluxer issues a UUID for an attachment and `stream_previews/{name}-{digest}.jpg` | Content-Length?1 | integer | The declared length of the request body | | Content-Type?2 | string | The media type to store, read only when the capability declares none | -1 A declared length above the capability `mb` field or above the endpoint body limit returns 413 +1 A declared length above the authorised upload size or the endpoint body limit returns 413 -2 The capability `ct` field takes precedence over it +2 The media type authorised by the upload URL takes precedence The relay ignores every other request header. @@ -117,9 +71,7 @@ The relay ignores every other request header. The body is arbitrary bytes. -With `Content-Length`, the relay streams the body to the object store as it arrives. A body longer than the declared length returns 413 and a body shorter than it returns 400. - -Without `Content-Length`, the relay spools the body to a temporary file first, bounded by the smaller of the capability `mb` field and the endpoint body limit. Exceeding that bound returns 413. A client transfer that fails part way returns 400. The relay returns 503 when the endpoint spool budget cannot cover the request. +When `Content-Length` is supplied, send exactly that many bytes. A longer body returns 413 and a shorter body returns 400. The authorised upload size and endpoint body limit apply even without this header. Interrupted uploads return 400, and insufficient relay capacity returns 503. ### Response @@ -133,10 +85,10 @@ The relay forwards no object storage response body and no arbitrary response hea | 403 | `Forbidden` | The bucket, key, `uploadId`, or `partNumber` disagrees with the capability | | 404 | `Not Found` | The endpoint does not serve the upload relay | | 4051 | empty | The method is not `PUT` | -| 413 | `Payload Too Large` | The declared or delivered body exceeds the capability `mb` field or the endpoint body limit | -| 500 | `Internal Server Error` | A spool write to the relay's own disk failed | +| 413 | `Payload Too Large` | The body exceeds the authorised upload size or the endpoint body limit | +| 500 | `Internal Server Error` | The relay could not store the upload locally | | 502 | `Bad Gateway` | The object store refused the write or could not be written to | -| 503 | `Service Unavailable` | The endpoint spool budget cannot cover the request | +| 503 | `Service Unavailable` | The relay has insufficient upload capacity | 1 It has no relay CORS headers and no cache policy @@ -146,7 +98,7 @@ A successful relay response stores bytes only. [Create message](/http-api/messag [Complete attachment upload](/http-api/messages/#complete-attachment-upload) assembles a multipart upload once every part is stored, and a singlepart upload needs no completion step. A stream preview becomes readable as soon as the stored object is in place, and the [Streams resource](/http-api/streams/) defines that flow. -The relay has no replay tracking. Repeating a request with the same capability writes the object again, and the last successful write wins. Retrying a multipart part after a 502 or 503 is safe, because a part is addressed by its number and the repeated write replaces it. +Repeating an upload replaces the object or part, and the last successful write wins. A multipart part can be retried after a 502 or 503 while its URL remains valid. ### Response headers @@ -163,12 +115,8 @@ A successful request writes the object the capability selects to the uploads buc | Setting | Default | Configurable range | | --- | --- | --- | | Endpoint body limit | 500 MiB | 1 byte through 5 GiB | -| Spool buffer | 1 MiB | 64 KiB through 64 MiB | -| Endpoint spool budget | 8 GiB | Up to 256 GiB | | Object storage write deadline | 900,000 ms | 1,000 through 3,600,000 ms | -The effective limit for one request is the smaller of the endpoint body limit and the capability `mb` field. - -The relay writes a spooled body to its temporary file through the spool buffer. Spooled bodies share one endpoint budget, and a request that arrives with no `Content-Length` reserves the complete effective limit from that budget before the relay reads a byte. The endpoint refuses to start when its body limit exceeds that budget, so one reservation always fits an idle endpoint. +The effective limit is the smaller of the endpoint body limit and the authorised upload size. For a streamed body the relay extends the object storage write deadline by one second for every 16 KiB of declared length. diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index d7fea13ea..37af0d0c0 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -10,24 +10,21 @@ We are grateful to everyone supporting the project through [Fluxer Plutonium](ht Fluxer reads its settings from environment variables. They live in a file named `.env`, in the same directory as `docker-compose.yml`. -A first run touches the sections below. [Core identity and public address](#core-identity-and-public-address) has `FLUXER_DOMAIN`, the hostname people type into a browser. [Secrets you must generate](#secrets-you-must-generate) has the values that ship as `CHANGE_ME`. Everything after those two is optional and already has a working value. +For the bundled Compose stack, start with [Core identity and public address](#core-identity-and-public-address) and [Secrets you must generate](#secrets-you-must-generate). The remaining sections cover custom settings and external services. The installer in [Get started](/operator/get-started/) writes `.env` for you and fills in every secret. [Upgrading](/operator/upgrading/) covers moving between releases. ## How configuration is loaded -Compose reads `.env` and passes the values it names into containers. A name reaches a container only when `docker-compose.yml` lists it, either in the shared `x-fluxer-env` block or in that service's own `environment` block. No service declares `env_file`, so a name in `.env` that appears in neither block never arrives, whatever it is set to. +Compose passes only variables listed in `docker-compose.yml`. For an unlisted setting, add it to the service's environment through a local Compose override. Adding it to `.env` alone has no effect. -Compose reads a `$` inside a value as a variable reference. `POSTGRES_PASSWORD=ab$cd` reaches the container as `ab`, and every command against the stack prints `The "cd" variable is not set. Defaulting to a blank string.` first. Write the `$` as `$$`, or put single quotes around the whole value. Both deliver one literal `$`: +Single-quote values containing a literal `$` so Compose does not expand them as variable references: ```ini -POSTGRES_PASSWORD=ab$$cd POSTGRES_PASSWORD='ab$cd' ``` -Double quotes do not escape it. `docker compose config` prints a literal `$` back as `$$`. A value that reads `ab$$cd` in that output is the correct one. The secrets the installer generates are hex or base64 and hold no `$`, so this reaches an instance through a password, an API key or an SMTP secret pasted in by hand. - -A container's environment is fixed when the container is created, and `api` and `worker` cache their configuration at first load. Either way a change needs the process restarted, which `docker compose up -d` does by recreating the service. +A container's environment is fixed when it is created. Apply changes with `docker compose up -d`, which recreates affected services. `docker compose restart` keeps the old environment. Precedence, highest first: @@ -35,28 +32,7 @@ Precedence, highest first: 2. The environment variable. 3. The built-in default. -Each runtime reads the environment with its own parser, so the same name can have a different default in different services. - -#### Node named overrides - -Read by `api` and `worker`. A fixed table of `FLUXER_*` names. A name outside it is invisible. - -#### Rust direct reads - -Read by `media-proxy`, `app-proxy`, `admin`, and the internal services. Each crate reads its own names. Blank usually counts as unset. - -#### Erlang direct reads - -Read by `gateway`. Reads the OS environment. An unknown boolean falls back to the default. A bad integer fails startup. - -Parsing rules: - -- Node treats an empty string as set. `FLUXER_EMAIL_FROM_NAME=` overrides the `Fluxer` default with an empty name. -- Node booleans accept only `true` and `false`, in any letter case. `FLUXER_POSTGRES_SSL=1` parses as the number `1` and fails startup with `FLUXER_POSTGRES_SSL must be true or false`. -- Rust parses booleans differently by crate. `app-proxy` and `admin` treat anything other than `1`, `true`, `yes`, or `on` as false. `media-proxy` and the internal services reject an unrecognised value with a startup error. -- A value that starts with `{` or `[` is parsed as JSON. A failed parse fails startup with a message naming the variable. -- A name read as an integer fails startup on any other value, in Node and in the Gateway. An empty value takes the default. -- Comma separated lists are split on `,`, trimmed, and stripped of empty entries. +Use `true` or `false` for booleans, decimal integers for integer settings, and the specified object or array for JSON settings. Defaults and accepted values are listed below. Leave an unwanted override unset, since an empty value does not always restore the default. ## Core identity and public address @@ -70,13 +46,15 @@ Parsing rules: Outside Compose these fall back to an empty base domain, `http`, and port `8088`. Compose supplies `https` and `443`. +The API and worker require hostname-only domain settings: no scheme, port, credentials, path, query, fragment, or whitespace. Bracket IPv6 addresses. Valid hostname spelling is preserved, including case and a terminal root dot. + `FLUXER_DOMAIN` also feeds the default passkey relying party identifier, the default VAPID contact address, and the edge listener address. `FLUXER_PUBLIC_ORIGIN` states the same public address as one string, and [The public origin](#the-public-origin) has it. #### `FLUXER_INTERNAL_SCHEME` -Default `http`. The scheme for internal service URLs. Must be `http` or `https`, and anything else fails startup. Nothing outside the config loader reads it. +Default `http`. The scheme for internal service URLs. Must be `http` or `https`, and anything else fails startup. ## Secrets you must generate @@ -171,18 +149,18 @@ Nothing in the stack reads `X-Forwarded-Proto` or `X-Forwarded-Host`. Every abso `FLUXER_PUBLIC_ORIGIN` states the public address as one string, with no trailing slash: the scheme, the host, and the port when that port is not the default for the scheme. `FLUXER_PUBLIC_SCHEME`, `FLUXER_DOMAIN` and `FLUXER_PUBLIC_PORT` state the same address between them, so the spellings have to agree. -`docker-compose.yml` substitutes it into every name that needs a full origin, among them `FLUXER_APP_ENDPOINT`, `FLUXER_ADMIN_ENDPOINT`, `FLUXER_ADMIN_OAUTH_REDIRECT_URI`, `FLUXER_MEDIA_ENDPOINT`, `FLUXER_MARKETING_ENDPOINT`, `FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT`, `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT`, `FLUXER_STATIC_CDN_ENDPOINT`, `FLUXER_LIVEKIT_URL`, `FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS`, `PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT`, and the Gateway's media and static endpoints. `grep FLUXER_PUBLIC_ORIGIN docker-compose.yml` is the whole list. Compose also puts the name itself into the shared `x-fluxer-env` block, and a service that reads it takes its base domain, scheme and port from it. +Use an explicit `http://` or `https://` origin. Surrounding spaces and a single trailing slash are accepted. Credentials, paths, query strings, fragments, control characters, and empty or zero ports are rejected. -It ships commented out. When it is unset, Compose builds those names from `FLUXER_PUBLIC_SCHEME` and `FLUXER_DOMAIN`, and each service puts `FLUXER_PUBLIC_PORT` back into the endpoints it derives. +Leave it unset unless you need to state the full address explicitly. Compose otherwise derives the public URLs from the scheme, domain and port, including non-default ports. -A non-default port therefore needs nothing here. `FLUXER_PUBLIC_PORT` sets the port of the public address, and the rest of the stack follows it. The comment block under the first three lines of `.env.example` says what each layout needs. - -Set `FLUXER_PUBLIC_ORIGIN` only to write the address out in one place, and then repeat the port `FLUXER_PUBLIC_PORT` names and the host `FLUXER_DOMAIN` names inside it. An `.env` whose spellings disagree names two addresses. Half the instance answers on one and half on the other, and the web app sends no `Authorization` header to an API that is not on its own origin. +If you set it, keep these settings consistent with it. A mismatch can break sign-in, setup, passkeys and media access. ## Endpoint overrides Each of these replaces its derived endpoint wholesale. Set one only when part of the instance answers at an address the derivation does not produce. All are optional. +The API and worker validate endpoint URLs at startup. The Gateway endpoint requires `ws://` or `wss://`. The others require `http://` or `https://`. Path prefixes and explicit ports are allowed. Credentials, fragments, whitespace, and backslashes are rejected. HTTP endpoint bases cannot contain a query. The Gateway URL may retain query parameters. + #### `FLUXER_STATIC_CDN_DOMAIN` Default empty. A separate host for static assets. When set, the static endpoint is forced to `https` with no port. @@ -261,7 +239,7 @@ No default. The internal Media Proxy address. `unfurl-shard` reads this name alo #### `FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT` -No default. The public Media Proxy URL, read by `gifs`, `unfurl`, and `media-proxy` itself. `gifs` exits at boot without this or `FLUXER_MEDIA_ENDPOINT`. `api` and `worker` never read it. `media-proxy` trims trailing slashes from the value at load and locally resolves a media URL that matches it on scheme, host, port, and path prefix. An `/attachments/` path, a `/themes/` stylesheet, an entrance sound, and an image asset are read from the `FLUXER_S3_BUCKET_CDN` bucket, or from `FLUXER_S3_BUCKET_STATIC` in `static` mode. A signed `/external/{signature}/{target}` path has its signature verified and is unwrapped to the third-party target, which is then fetched. With this unset, `media-proxy` fetches every such URL over HTTP. +No default. The public Media Proxy URL used by `gifs`, `unfurl`, and `media-proxy`. `gifs` requires this or `FLUXER_MEDIA_ENDPOINT` to start. Set it on `media-proxy` so requests for its own assets use local storage instead of an HTTP round trip. For `api` and `worker`, use `FLUXER_MEDIA_ENDPOINT` instead. #### `FLUXER_UNFURL_STATIC_CDN_ENDPOINT` @@ -397,7 +375,7 @@ Default `fluxer_kv`. The key-value table name. Must match a safe Postgres identi Default `true`. Named prepared statements. Must be `true` or `false`. Compose passes it in the shared block, so one value governs `api`, `worker` and the Rust services at once. Set it to `false` behind a transaction-pooling pooler such as PgBouncer, where a named statement outlives the session that declared it. -Cassandra or Scylla is an alternative backend, selected with `FLUXER_DATABASE_BACKEND=cassandra`. The shipped stack does not use it and ships no Cassandra container. All are optional. +Cassandra or Scylla is an alternative backend, selected with `FLUXER_DATABASE_BACKEND=cassandra`. Supply a reachable database and configure the settings below when selecting it. The bundled stack uses Postgres and does not include a Cassandra or Scylla service. #### `FLUXER_CASSANDRA_HOSTS` @@ -425,15 +403,15 @@ Default empty. The password. Empty means no authentication. ## Cache and key-value -Fluxer uses Valkey, which speaks the Redis protocol, as its cache, its pub/sub bus, and its queue backend. All are optional. +The bundled stack requires Valkey for shared cache, live updates and deletion queues. General background jobs use [NATS JetStream](#message-bus-and-internal-services). Change these settings only when customising the cache or connecting an external Redis-compatible service. #### `FLUXER_KV_URL` -Default `redis://localhost:6379/0`. The Redis-protocol key-value store, pub/sub bus and queue backend. The `admin` service defaults to empty instead. Compose sets `redis://valkey:6379/0` everywhere. +Default `redis://localhost:6379/0`. The Redis-compatible service address. The `admin` service defaults to empty instead. Compose sets `redis://valkey:6379/0`. #### `FLUXER_KV_MODE` -Default `standalone`. Which client shape the API and the worker build. `standalone` or `cluster`. Compose leaves it unset. +Default `standalone`. Use `standalone` for a single Valkey server or `cluster` for a Redis-compatible cluster. #### `FLUXER_KV_PROVIDER` @@ -441,7 +419,7 @@ Default `redis`, which is the only accepted value. #### `FLUXER_SVC_CACHE_TTL_MS` -Default `30000`. Soft cache lifetime in the internal services. Milliseconds. +Default `30000`. Soft cache lifetime in the internal services. Milliseconds. The `users` and `messages` routers ignore this setting because they keep no configurable response cache. Their shard caches still honour it. #### `FLUXER_SVC_CACHE_HARD_TTL_MS` @@ -449,7 +427,7 @@ Default `600000`. Hard cache lifetime. Clamped to at least the soft TTL. #### `FLUXER_SVC_CACHE_MAX_ENTRIES` -Default `100000`. Cache entry ceiling. Per process. +Default `100000`. Cache entry ceiling. Per process. The `users` and `messages` routers ignore this setting because they keep no configurable response cache. Their shard caches still honour it. #### `FLUXER_GIFS_SHARD_CACHE_MAX_BYTES` @@ -459,9 +437,7 @@ Default `536870912`. GIF cache size. Must be at least 16777216. Set it to 134217 Default `300000`. How often the API refreshes its IP-ban cache. A non-finite value or one at or below zero disables the timer. -These sorted sets in the bundled Valkey have no expiry: `bulk_message_deletion_queue` and the account deletion queue. The worker rebuilds each of them from the users table whenever its state version is absent or older than a day, so a lost set costs one rebuild and up to a day of delay. `docker-compose.yml` still starts Valkey with `--appendonly yes`, `--appendfsync everysec`, a named volume and `noeviction`. An over-limit write then returns an error to the caller and drops no queued work. - -Distributed locks in the same store all have a TTL, so they expire on their own. [Volumes and buckets](#volumes-and-buckets) states what losing `valkey-data` costs. +Keep persistent storage and the bundled `noeviction` policy to protect deletion queues and other shared state. Back up `valkey-data`. See [Volumes and buckets](#volumes-and-buckets) for recovery implications. ## Object storage @@ -505,7 +481,7 @@ Default `fluxer-uploads`. Raw uploads. `media-proxy` defaults to `uploads`, `app #### `FLUXER_S3_BUCKET_DOWNLOADS` -Default `fluxer-downloads`. Desktop build artifacts. Read by `api` and `worker`. +Default `fluxer-downloads`. Desktop build artefacts. Read by `api` and `worker`. #### `FLUXER_S3_BUCKET_REPORTS` @@ -543,7 +519,7 @@ Compose sets `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, ## Search -Message search runs against Meilisearch in the shipped stack. All are optional. +The bundled stack provides Meilisearch for message search. These settings select and connect the search service. #### `FLUXER_SEARCH_ENGINE` @@ -571,7 +547,7 @@ Default `true`. Certificate verification. Elasticsearch only. Never passed to th ## Message bus and internal services -NATS is the message bus between Fluxer processes, and small internal services sit behind it. All are optional. +The bundled stack requires NATS for communication between services and JetStream for background jobs. Compose supplies the connection settings. Change them when using an external NATS service, which must have JetStream enabled for the worker connection. #### `FLUXER_NATS_URL` @@ -583,7 +559,7 @@ Alias of `FLUXER_NATS_URL` on `api` and `worker`, read only when that name is un #### `FLUXER_NATS_JETSTREAM_URL` -Default `nats://127.0.0.1:4222`. The JetStream address. Read by `api` and `worker`. +Default `nats://127.0.0.1:4222`. The JetStream address for `api` and `worker`. Compose uses `FLUXER_NATS_URL` unless this setting is supplied separately. #### `FLUXER_NATS_AUTH_TOKEN` @@ -597,15 +573,15 @@ Default `nats://127.0.0.1:4222`. The NATS address for the internal services. A s No default. Where the Gateway calls the API. Read by the Gateway. -The internal services read the same topology variables. All are optional. +Compose configures the internal services. Change the following settings only when customising their deployment. #### `FLUXER_SVC_NAME` -Default `default`. The metrics prefix and the concurrency default. NATS subjects come from a hardcoded per-crate name. Compose sets it to the crate name on every internal service container. +Default `default`. The metrics prefix and the concurrency default. Changing it does not rename NATS subjects. #### `FLUXER_SVC_MODE` -Default `router`. Router or shard. Anything but the exact string `shard` is a router. +Default `router`. Must be `router` or `shard`. Any other value prevents startup. #### `FLUXER_SVC_SHARD_COUNT` @@ -631,11 +607,24 @@ Defaults to 192 for messages, 320 for snowflakes, 64 otherwise. In-flight reques No default. The shard ordinal source and node identity. Also read by the Gateway and by API RPC timing. -The API tunes its NATS clients through these names, none of which are in the override table or in `.env.example`. Each falls back to its default when the value is not a positive number. +For custom service routing, add these settings to the API container environment. The bundled Compose file does not forward them: -- Snowflake service: `FLUXER_SNOWFLAKE_SERVICE_SUBJECT`, `FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME`, `FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE`, `FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK`, `FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS`, and `FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS`. -- Users service: `FLUXER_USERS_SERVICE_SUBJECT`, `FLUXER_USERS_SERVICE_NATS_CLIENT_NAME`, `FLUXER_USERS_SERVICE_TIMEOUT_MS`, and `FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES`. -- GIF service: `FLUXER_GIF_SERVICE_SUBJECT`, `FLUXER_GIF_SERVICE_NATS_CLIENT_NAME`, `FLUXER_GIF_SERVICE_TIMEOUT_MS`, and `FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS`. +- Snowflake service: `FLUXER_SNOWFLAKE_SERVICE_SUBJECT` and `FLUXER_SNOWFLAKE_SERVICE_NATS_CLIENT_NAME`. +- Users service: `FLUXER_USERS_SERVICE_SUBJECT` and `FLUXER_USERS_SERVICE_NATS_CLIENT_NAME`. +- GIF service: `FLUXER_GIF_SERVICE_SUBJECT` and `FLUXER_GIF_SERVICE_NATS_CLIENT_NAME`. + +Numeric settings require decimal safe integers. Surrounding whitespace is trimmed, and omitted or blank settings use the defaults. Explicit malformed or out-of-range values are rejected. + +- `FLUXER_SNOWFLAKE_SERVICE_BATCH_SIZE`: defaults to `128`. Accepts 1 to 512. +- `FLUXER_SNOWFLAKE_SERVICE_LOW_WATERMARK`: defaults to `32`, capped below the batch size. Accepts 0 through batch size minus one. +- `FLUXER_SNOWFLAKE_SERVICE_MAX_BUFFER_AGE_MS`: defaults to `5000`. Accepts 1 to 60000 milliseconds. +- `FLUXER_SNOWFLAKE_SERVICE_REQUEST_TIMEOUT_MS`: defaults to `6000`. Accepts 1 to 60000 milliseconds. +- `FLUXER_USERS_SERVICE_TIMEOUT_MS`: defaults to `6000`. Accepts 1 to 2147483647 milliseconds. +- `FLUXER_USERS_SERVICE_INFLIGHT_MAX_ENTRIES`: defaults to `10000`. Accepts 0 through the largest safe integer. +- `FLUXER_GIF_SERVICE_TIMEOUT_MS`: defaults to `12000`. Accepts 1 to 2147483647 milliseconds. +- `FLUXER_GIF_SERVICE_REGISTER_SHARE_TIMEOUT_MS`: defaults to `3000`. Accepts 1 to 2147483647 milliseconds. + +A Snowflake low watermark of `0` disables background refill before the buffer is empty. A Users in-flight entry limit of `0` disables request coalescing. It is not a limit on concurrent requests. ## Voice and LiveKit @@ -697,39 +686,11 @@ LiveKit media does not go through the edge. Compose publishes both media ports d Moving a media port is one line in `.env` followed by `docker compose up -d livekit`. Compose puts the same value on the host side of the mapping, on the container side, and on the `rtc` port LiveKit advertises in the ICE candidates it hands to clients. -Moving the key pair takes these steps. Set both names in `.env`, then run `docker compose up -d livekit api worker` so LiveKit restarts on the new key and the API rebuilds its webhook receivers and upserts the stored voice server row. +To rotate the key pair, set both names in `.env`, then run `docker compose up -d livekit api worker` to apply the change everywhere. The `Caddyfile` is a bind mount, so the edge reads the copy that sits on disk beside `docker-compose.yml`. Editing it takes `docker compose restart edge`, because `docker compose up -d` leaves a container alone when only a mounted file changed. An upgrade does that restart itself, which [What the script does](/operator/upgrading/#what-the-script-does) covers. A change to any LiveKit value in `.env` needs `docker compose up -d`, because `restart` reuses the existing container with its old environment. -Voice reconciliation runs in `worker`. All are optional. - -#### `FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION` - -Default `true`. Whether the sweeper runs. Compose sets `true` explicitly. - -#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_INTERVAL_MS` - -Default `15000`. Sweep interval. Milliseconds. - -#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_STAGGER_DELAY_MS` - -Default `25`. Delay between rooms. Milliseconds. - -#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_LOCK_TTL_SECONDS` - -Defaults to 180 or three intervals, whichever is larger. Sweep lock lifetime. Seconds. - -#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_CADENCE_TTL_SECONDS` - -Defaults to three intervals, at least 1. Cadence marker lifetime. Seconds. - -#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_GATEWAY_ONLY_GRACE_MS` - -Default `10000`. Grace before culling a Gateway-only participant. Milliseconds. - -#### `FLUXER_API_WORKER_VOICE_RECONCILIATION_LIVEKIT_ONLY_GRACE_MS` - -Default `60000`. Grace before culling a LiveKit-only participant. Milliseconds. +Voice reconciliation has moved from `worker` to the separate recon service. The old `FLUXER_API_WORKER_ENABLE_VOICE_RECONCILIATION` and `FLUXER_API_WORKER_VOICE_RECONCILIATION_*` settings no longer control it. The bundled Compose stack does not start the recon service. ## Email @@ -812,13 +773,19 @@ Default `Fluxer`. The relying party name browsers display. Does not follow `FLUX #### `FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS` -Defaults to the origin of the public web app endpoint. The complete accepted origin set. Comma separated. Despite the name, a value must list every origin browsers use. +The complete accepted origin set, comma separated. Despite the name, an explicit value replaces the defaults and must list every origin clients use. Compose defaults it to the public web app origin. When the variable is omitted outside Compose, the API accepts the configured app origin alongside the built-in Fluxer web and Android origins. An empty list selects only the configured app origin. + +Each web entry must be an HTTP(S) origin with no credentials, path beyond an optional final slash, query, or fragment. Web origins are normalised to browser form using the configured public port unless the entry supplies one. Android entries must use `android:apk-key-hash:` followed by the signing certificate's SHA-256 fingerprint in canonical, unpadded base64url (43 characters). Invalid entries and control characters fail startup. :::caution[Changing the relying party identifier invalidates every passkey] A passkey is bound to the `FLUXER_PASSKEY_RP_ID` it was registered under. Members have to enrol again after a change, so pick the value before opening registration. ::: -Bluesky OAuth login is off by default and is configured through `FLUXER_AUTH_BLUESKY_ENABLED`, `FLUXER_AUTH_BLUESKY_CLIENT_NAME`, `FLUXER_AUTH_BLUESKY_CLIENT_URI`, `FLUXER_AUTH_BLUESKY_LOGO_URI`, `FLUXER_AUTH_BLUESKY_TOS_URI`, `FLUXER_AUTH_BLUESKY_POLICY_URI`, and `FLUXER_AUTH_BLUESKY_KEYS`. None reach a container in the shipped stack, and the effective state also requires at least one key. The terms and policy URIs have no default, and the client metadata omits them until they are set. +## Bluesky connections + +Bluesky connections are off by default. Enable them in the admin dashboard's Runtime Integrations panel and supply an ES256 private signing key with a unique key identifier. The public API must serve the [client metadata and signing keys](/http-api/connections/#get-bluesky-client-metadata). + +For environment-based configuration, use `FLUXER_AUTH_BLUESKY_ENABLED`, `FLUXER_AUTH_BLUESKY_KEYS`, and the optional `FLUXER_AUTH_BLUESKY_CLIENT_NAME`, `FLUXER_AUTH_BLUESKY_CLIENT_URI`, `FLUXER_AUTH_BLUESKY_LOGO_URI`, `FLUXER_AUTH_BLUESKY_TOS_URI`, and `FLUXER_AUTH_BLUESKY_POLICY_URI` settings. Add them to the API container environment. The shipped Compose file does not forward them. ## Web push @@ -922,7 +889,7 @@ Individual price variables also exist, one per product and currency: `FLUXER_STR #### `FLUXER_STRIPE_LEGACY_PRICES` -Default `{}`. Retired price IDs, keyed by the same slot names `FLUXER_STRIPE_PRICES` uses, each mapped to a list: `{"monthly_brl": ["price_..."]}`. JSON object. A subscription still billing on one of these keeps renewing, while the localized checkout catalog is still built from `FLUXER_STRIPE_PRICES` alone. +Default `{}`. Retired price IDs, keyed by the same slot names `FLUXER_STRIPE_PRICES` uses, each mapped to a list: `{"monthly_brl": ["price_..."]}`. JSON object. Existing subscriptions on these prices keep renewing. The localised checkout catalogue uses `FLUXER_STRIPE_PRICES` alone. Repricing a slot is ordered, and the order is not reversible without failed invoices. Create the new price in Stripe, move the ID it replaces into this variable, roll the API, and only then point `FLUXER_STRIPE_PRICES` at the new price. A price ID that neither variable names is unknown to the API: a renewal invoice on it fails the webhook with `Unknown product for invoice renewal`, a checkout completing on it fails with `Unknown price ID for checkout session`, and both keep failing until the ID is registered. The API answers Stripe as soon as the signature verifies and hands the event to `worker`, so the retries are the `processStripeWebhook` job's own: the Stripe dashboard shows the delivery as succeeded and the error is in the `worker` logs. Keep a retired ID listed for as long as any subscription still bills on it, which for a yearly price is at least a year after the switch. @@ -1036,7 +1003,7 @@ Other names in that family limit how often the auto-banner buys an IP classifica - `FLUXER_ABUSE_IP_CLASS_NEGATIVE_TTL_MS` defaults to `300000` milliseconds and sets how long a failed classification is remembered. - `FLUXER_ABUSE_IP_CLASS_HINT_TTL_MS` defaults to `600000` milliseconds and sets how long a class sent by another replica stays usable. -The claim key is `abuse:ipclass:claim:` plus the ban key, and replicas send classes to each other on the `abuse_tracker:ipclass` key-value channel. The numeric names are read through the same helper as the names above, so a non-finite value or one at or below zero falls back to the default. +For these numeric settings, a non-finite value or one at or below zero falls back to the default. A `FLUXER_IPINFO_BUDGET_` family caps what the instance spends at ipinfo. `FLUXER_IPINFO_BUDGET_ENABLED` defaults to `1`, and `0` turns off all shedding. `FLUXER_IPINFO_BUDGET_MONTHLY_MAX` defaults to `140000` and is the ceiling for one UTC calendar month. Lookups run at the priorities below, each with a share of that ceiling and a token bucket for bursts refilled once a minute. @@ -1044,13 +1011,29 @@ A `FLUXER_IPINFO_BUDGET_` family caps what the instance spends at ipinfo. `FLUXE - Registration risk is standard. It stops at `FLUXER_IPINFO_BUDGET_STANDARD_MONTHLY_PCT` percent of the ceiling, default `90`, with a burst of `240` from `FLUXER_IPINFO_BUDGET_STANDARD_BURST` refilled at `120` a minute by `FLUXER_IPINFO_BUDGET_STANDARD_REFILL_PER_MIN`. - The IP auto-banner is background. It stops at `FLUXER_IPINFO_BUDGET_BACKGROUND_MONTHLY_PCT` percent, default `60`, with a burst of `120` from `FLUXER_IPINFO_BUDGET_BACKGROUND_BURST` refilled at `30` a minute by `FLUXER_IPINFO_BUDGET_BACKGROUND_REFILL_PER_MIN`. -The lower ceilings mean background lookups stop first and critical lookups stop last. The counters live in the key-value store under `ipinfo:budget:burst:` and `ipinfo:budget:month:`. A shed lookup returns an unavailable result and raises no error, and any key-value failure admits the lookup at every priority, so a cache outage never stops an admin ban or the auto-banner. +Background lookups stop first and critical lookups stop last. A denied lookup returns an unavailable result. If the key-value store fails, lookups remain allowed at every priority, so an outage can increase usage beyond these budgets. -The names below let the local MaxMind databases answer the registration risk lookup. `FLUXER_RISK_IPINFO_PRESCREEN_ENABLED` is off by default and only `1` or `true`, in any letter case, turns it on. `FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS` is a comma-separated list of ASN numbers, empty by default, and non-numeric entries are dropped. The pre-screen therefore does nothing until an operator fills the list in. An IP skips ipinfo only when the local city database returns a country, the local ASN database returns a number, that number is in the list, and the ASN organization name is not a commercial privacy provider, an education network or a cellular network. Everything else still goes to ipinfo. +Local MaxMind data can reduce registration-risk lookups. Set `FLUXER_RISK_IPINFO_PRESCREEN_ENABLED` to `1` or `true`, in any letter case, and fill `FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS` with comma-separated ASN numbers. Non-numeric entries are dropped. An IP skips ipinfo only when MaxMind supplies its country and an allowed ASN, and the organisation is not a commercial privacy provider, education network or cellular network. + +## Stored instance policy + +Use the [admin dashboard](#runtime-settings-in-the-admin-dashboard) or [Admin instance API](/admin-api/instance/) to change saved settings. Missing settings use their documented defaults. Invalid JSON, field types, identifiers or out-of-range values cause an error instead of silently resetting security or registration policy. + +Invalid saved configuration prevents the API and worker from starting. If a running process cannot apply an update, it logs the error and keeps its previous valid settings. Check the reported section and field paths, repair the saved configuration, then retry. It is not repaired automatically. + +Older configurations retain these migration rules: + +- Gateway rollout accepts `nats_request_timeout_ms` only when `rpc_request_timeout_ms` is absent. Both require an integer from 1000 to 60000. Use the current name in Admin requests. +- Registration accepts `adminRegistrationUrlsEnabled` only when `admin_registration_urls_enabled` is absent. Missing registration settings default to `open` with admin registration URLs enabled. +- Missing SSO settings default to disabled, with enforcement following enablement and automatic provisioning enabled. Stored flags require `true` or `false`. +- SSO allowed domains accept a JSON string array or a legacy comma-separated list of at most 100 entries. Domains are trimmed, lowercased, IDNA encoded and deduplicated. An empty list leaves domains unrestricted. Invalid lists must be repaired even while SSO is disabled. +- Missing registration URL and pending-registration lists mean empty lists. Invalid records are rejected rather than discarded. Timestamps require an explicit UTC marker or offset, and null is accepted only for nullable fields. + +Explicit `false` values and supported null clears remain valid. Branding and integration strings retain their documented trimming and blank-value behaviour. Media size and lifetime adjustments also remain unchanged. These checks do not make a multi-section update atomic. Read back the result after a failed write before retrying. ## Limits -No environment variable changes an instance limit. Fluxer keeps the limits in the key-value store under `limit_config:self_hosted` or `limit_config:saas`, seeded from `FLUXER_SELF_HOSTED`, and an operator edits them through the admin dashboard's Limit Config page or the [Admin API](/admin-api/). The published values are the [limit configuration object](/http-api/instance/#limit-configuration-object). +No environment variable changes an instance limit. Use the admin dashboard's Limit Config page or the [Admin API](/admin-api/). Effective limits combine saved settings, deployment defaults and premium policy. Changes take effect after a short propagation delay. Clients receive the [limit configuration object](/http-api/instance/#limit-configuration-object). Request concurrency is separate from instance limits, and each process sets its own. All are optional. @@ -1268,9 +1251,7 @@ Default empty. The guild that grants the visionary role. Snowflake. Default empty. The role granted there. Snowflake. -That stored row lives in the `instance_configuration` table under `app_public_config`. Unparseable JSON or a non-boolean `setup.configured` falls through to `FLUXER_INSTANCE_SETUP_CONFIGURED` the same way an absent row does. Finishing the wizard writes the row, and every later admin write of branding or legal URLs rewrites it with the field still set. - -Setting the variable back to `false` therefore does not reopen the setup wizard, and it does not restore the unauthenticated instance-configuration access or the first-registration admin grant that [Get started](/operator/get-started/) describes. +Completing setup saves that state independently of the environment default. Later branding or legal updates preserve it. Setting `FLUXER_INSTANCE_SETUP_CONFIGURED` back to `false` does not reopen the wizard or restore the unauthenticated setup access and first-registration admin grant described in [Get started](/operator/get-started/). ## API and worker settings @@ -1394,7 +1375,7 @@ Default `30000`. Socket read and write timeout. Accepts 0 to 300000. #### `FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS` -Default `30000`. The deadline for the shutdown drain after SIGTERM or Ctrl-C. Accepts 0 to 300000. In-flight requests, the transform coalescer, and native transform tasks share the one deadline, and passing it exits the process with an error. +Default `30000`. Milliseconds allowed to finish requests and media transforms after SIGTERM or Ctrl-C. Accepts 0 to 300000. Exceeding the deadline exits the process with an error. #### `FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS` @@ -1660,11 +1641,11 @@ Become `FLUXER_LIVEKIT_API_KEY` and `FLUXER_LIVEKIT_API_SECRET`, and LiveKit's o ## Keys Compose does not forward -`.env.example` names every variable `docker-compose.yml` reads from `.env`. Compose forwards a fraction of the Node override names, the Rust-only names and the Erlang-only names. A name below reaches a service only through a Compose override file that adds it to that service's environment block. +`.env.example` lists the variables forwarded by `docker-compose.yml`. Add any setting below through a Compose override file that puts it in the relevant service's environment. #### `FLUXER_AUTH_BLUESKY_` and the names under it -Bluesky login defaults off. The admin dashboard configures it too, under Runtime Integrations. +See [Bluesky connections](#bluesky-connections) for configuration. #### `FLUXER_PUSH_APNS_` and `FLUXER_PUSH_FCM_` @@ -1712,7 +1693,7 @@ Size-based attachment lifetimes. The built-in default is on. #### Instance Config, Gateway Rollout Configuration -Session and guild rollout percentages, NATS timeouts, and Gateway concurrency. +Session and guild rollout percentages, RPC timeouts, and Gateway concurrency. #### Instance Config, Single Sign-On (SSO) @@ -1730,7 +1711,7 @@ The voice regions offered and the servers behind them. Credentials for the [Admin API](/admin-api/). -Each write publishes a refresh on the key-value pub/sub channel, so other processes drop their cached copy without a restart. +Changes propagate without a restart. A process that cannot apply an update logs the error and keeps its previous valid settings. These settings exist only in the dashboard: attachment decay, which defaults to on, and the inactivity deletion threshold, which defaults to 365 days. @@ -1744,7 +1725,7 @@ The stack runs its containers on one Docker bridge network, which is private to | app-proxy | fluxer-app-proxy-self-hosted | Serves the web client and builds its CSP header | | static-proxy | fluxer-static | Serves the static asset bundle | | api | fluxer-api | The HTTP API | -| worker | fluxer-api | Background lanes, the cron scheduler, and voice reconciliation | +| worker | fluxer-api | Background lanes and the cron scheduler | | gateway | fluxer-gateway | The Gateway WebSocket | | media-proxy | fluxer-media-proxy | Uploads, transforms, and media delivery | | admin | fluxer-admin | The admin dashboard | @@ -1941,7 +1922,7 @@ Default `128MB`. The budget for each autovacuum worker. Postgres runs three work The remaining Postgres settings are fixed on the command line, with no variable of their own: `min_wal_size=512MB`, `max_wal_size=2GB`, `wal_buffers=16MB`, and `shm_size: 256mb` on the service itself. -The bundled Valkey holds durable state as well as cache, so it runs with an append-only file and refuses a write above its ceiling. All are optional. +The bundled Valkey uses persistent storage. These settings control its memory limit and behaviour when full. #### `FLUXER_VALKEY_MAXMEMORY` @@ -1949,7 +1930,7 @@ Default `192mb`. The dataset ceiling. Bounds stored keys only. Client buffers, r #### `FLUXER_VALKEY_MAXMEMORY_POLICY` -Default `noeviction`. What happens to a write above the ceiling. Under `noeviction` an over-limit write returns an OOM error to the caller. Under any eviction policy Valkey can drop the deletion queues and the distributed locks. Every lock has a TTL, and the worker rebuilds both queues from the users table within a day. [Volumes and buckets](#volumes-and-buckets) has what dropping a queue costs. +Default `noeviction`. An over-limit write returns an OOM error. Keep this policy to protect deletion queues and other shared state. See [Volumes and buckets](#volumes-and-buckets) for recovery implications. No service sets a CPU limit, a CPU reservation or `cpu_shares`, so every container sees the host's full CPU count. Bound the Gateway's scheduler count with `FLUXER_ERLANG_SCHEDULERS_MIN` and `FLUXER_ERLANG_SCHEDULERS_MAX`, or pin it with `FLUXER_ERLANG_SCHEDULERS` and `FLUXER_ERLANG_DIRTY_CPU_SCHEDULERS` from [Gateway settings](#gateway-settings). @@ -2017,15 +1998,17 @@ CORS origins are exactly the app and marketing endpoints. Serving the client fro | --- | --- | --- | | postgres-data | Every account, message, and configuration row | Yes | | seaweedfs-data | Every uploaded file | Yes | -| valkey-data | The deletion queues, held locks, and cached values | Yes | -| nats-data | The JetStream `JOBS` and `JOBS_DLQ` streams, on file storage | Yes | +| valkey-data | Deletion queues and shared cache | Yes | +| nats-data | Pending and failed background jobs | Yes | | edge-data | Issued TLS certificates | Optional, a loss only costs a re-issue | | edge-config | The edge's own state | No | | meilisearch-data | The search index, rebuildable | No | -`valkey-data` reads as a cache and holds queued work. Both deletion queues survive its loss, because the worker rebuilds each sorted set from the users table whenever its state version is absent or older than a day. Locks and cached values are the disposable part of the volume. +Losing `valkey-data` can delay scheduled account and bulk-message deletions while their queues are rebuilt. Pending asset deletions and CDN purges can be lost, so do not treat this volume as disposable cache. -`nats-data` holds queued work. `JOBS` is a workqueue stream on file storage with a maximum age of 7 days, and `JOBS_DLQ` keeps dead-lettered jobs for 30. A newly created `JOBS_DLQ` also has a 64 MiB cap and drops its oldest jobs once full, so a busy dead-letter stream loses them well before 30 days. If the JetStream store has no room for 64 MiB, the cap halves down to a floor of 8 MiB. If even 8 MiB does not fit, the stream is never created and failed jobs stay in `JOBS` until they expire. Losing the volume drops every job that had not run yet, and nothing replays them from the job ledger in Postgres. +`nats-data` retains pending jobs in `JOBS` for up to 7 days and failed jobs in `JOBS_DLQ` for up to 30 days. A full jobs stream rejects new work. A full dead-letter stream drops its oldest entries, so investigate failures promptly. If dead-letter storage is unavailable, failed jobs remain in `JOBS` only until they expire. Losing this volume loses queued work, which is not automatically recovered from the database. + +Startup refuses incompatible queue limits and never rewrites an existing stream. Stop publishers and workers before migrating an incompatible stream. Volume names are prefixed with the Compose project name, so `postgres-data` is `fluxer_postgres-data` on the host. @@ -2035,8 +2018,8 @@ Volume names are prefixed with the Compose project name, so `postgres-data` is ` | --- | --- | | fluxer | Avatars, guild and entity assets, themes, entrance sounds, memes, and processed attachments | | fluxer-uploads | Raw attachment uploads, before processing | -| fluxer-downloads | Desktop client build artifacts | +| fluxer-downloads | Desktop client build artefacts | | fluxer-reports | Abuse report evidence, and NCMEC payloads where that integration is on | | fluxer-harvests | User and guild data archives | -An attachment lands in `fluxer-uploads` first. Once processing succeeds, Fluxer copies it into `fluxer` and deletes the original. `FLUXER_S3_BUCKET_STATIC` names a further bucket that `seaweedfs-init` skips and that the stack's mode never reads. +`FLUXER_S3_BUCKET_STATIC` names an optional bucket. The shipped stack does not use or create it. diff --git a/fluxer_docs/src/content/docs/operator/get-started.mdx b/fluxer_docs/src/content/docs/operator/get-started.mdx index 4590f2c2a..35b20277e 100644 --- a/fluxer_docs/src/content/docs/operator/get-started.mdx +++ b/fluxer_docs/src/content/docs/operator/get-started.mdx @@ -27,7 +27,7 @@ By the end you have a Fluxer instance on a hostname you own, running the web app | Outbound | Working DNS resolution from inside the containers | | Host tools | `curl` and `openssl` on Linux. macOS ships both. The Windows installer needs neither | -Those memory limits are ceilings that reserve nothing, so the host never needs their total free. On a 4 GB host, lower the `api` and `worker` limits first, because those two size their JavaScript heap from whatever limit they are given. Under Docker Desktop the figure to compare against is the RAM you have given its Linux virtual machine, which starts well below what the hardware has. +Memory limits are ceilings, not reservations. On a 4 GB host, lower the `api` and `worker` limits first. With Docker Desktop, check the RAM assigned to its Linux virtual machine rather than the host's total. The stack is one Compose project. The edge and LiveKit are the only services that publish ports. @@ -155,7 +155,7 @@ docker compose up -d Host 80 stays published on the `https` recipe and still answers the ACME challenge. Let's Encrypt only ever connects to the public 80 or 443, so the certificate is issued if a router in front forwards public 80 to this host. Serve your own certificate from the `Caddyfile` when it cannot. -Leave `FLUXER_PUBLIC_ORIGIN` commented out. It states that same address as one string, so an `.env` that sets it without the port `FLUXER_PUBLIC_PORT` names holds two addresses. Part of the instance then answers on one and part on the other, the web app sends no `Authorization` header to an API that is not on its own origin, the API answers 401, and the setup wizard reads that 401 as an expired session and returns to the account form. [The public origin](/operator/configuration/#the-public-origin) has the rest. +Leave `FLUXER_PUBLIC_ORIGIN` commented out, or keep it consistent with the public scheme, domain and port. Conflicting addresses can cause sign-in failures and setup loops. See [The public origin](/operator/configuration/#the-public-origin). ### Installer flags @@ -180,7 +180,7 @@ Run `--dry-run` first to see what a set of flags does. The PowerShell script tak ### The script is the reference -Every step in the script has a comment beside it with the command that does that step alone and why it exists: where each stack file comes from, how `.env` is built from `.env.example`, what each `CHANGE_ME` takes, and how the VAPID pair is derived. Read it at [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or the Windows script at [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1). +Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it. ## Step 5: Check that it works @@ -220,7 +220,7 @@ Every path returns 200. `/.well-known/fluxer` lists the endpoints clients use, w Open the hostname in a browser. A fresh instance is unconfigured, so it serves the setup wizard. -The wizard runs in two halves. The first is a welcome, a theme choice, an admin introduction and an account form. Once that account exists, the second covers branding, registration mode, community policy, media expiry, the integrations, the service toggles and the premium model. Finishing marks the instance configured and takes the wizard down. +Create an account, then choose branding, registration mode, community policy, media expiry, integrations and premium settings. Completing the wizard closes setup access. Create the owner account with an email address at a domain you control. The first registration that supplies one receives the wildcard admin ACL, which is full access to the admin dashboard, unless registration is set to hold new accounts for approval. Finishing the wizard grants that same ACL to whichever account completes it, when that account holds none. @@ -236,7 +236,7 @@ The desktop client opens the hosted web app for its release channel, so reach yo ## Backups -An instance comes back from these artifacts and its `.env`: a dump of the database, which covers `postgres-data`, and a tarball of `seaweedfs-data`, which holds every upload, avatar, report and harvest. [Volumes and buckets](/operator/configuration/#volumes-and-buckets) lists every volume and what it holds. +Recovery requires `.env`, a database dump and a backup of `seaweedfs-data`, which holds uploads, avatars, reports and harvests. See [Volumes and buckets](/operator/configuration/#volumes-and-buckets) for everything that needs backing up. The dump costs no downtime, so run it on a schedule while the stack serves: @@ -269,7 +269,7 @@ In PowerShell write `${PWD}` in place of `$PWD`. You are done when `backups` holds a dump and a tarball and every service reads `running` again. -`sh install.sh --update` takes both artifacts before every upgrade. [What the backup covers](/operator/upgrading/#what-the-backup-covers) puts the dump on a nightly timer, and [Restore a backup](/operator/upgrading/#restore-a-backup) puts either artifact back. +`sh install.sh --update` takes both backups before upgrading. See [What the backup covers](/operator/upgrading/#what-the-backup-covers) for scheduled backups and [Restore a backup](/operator/upgrading/#restore-a-backup) for recovery. ### Remove the instance diff --git a/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx b/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx index 4d90526fb..7f01a9afe 100644 --- a/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx +++ b/fluxer_docs/src/content/docs/operator/reverse-proxy.mdx @@ -49,7 +49,7 @@ curl -i http://127.0.0.1:8080/_health `/_health` returns `200 OK` from the edge itself without reaching an upstream, so it is the health check to give your proxy. :::note[There is nothing to configure inside the instance] -The routing lives in a config file inside the stack. Point your proxy at the port and leave that file alone. +Forward all paths unchanged to the edge. No internal routing changes are needed. ::: ## What every proxy must do @@ -115,7 +115,7 @@ FLUXER_PUBLIC_PORT=8443 Your proxy holds the public port, and the overlay pins the edge to plain HTTP on `8080` whatever that port is, so nothing else in `.env` moves with it. -Leave `FLUXER_PUBLIC_ORIGIN` commented out. It states the same address as one string, and an `.env` that sets it without the port `FLUXER_PUBLIC_PORT` names puts part of the instance on `chat.example.com:8443` and part on `chat.example.com`, where the web app sends no `Authorization` header across the gap. [The public origin](/operator/configuration/#the-public-origin) has the rest. +Leave `FLUXER_PUBLIC_ORIGIN` commented out, or keep it consistent with all three settings. Conflicting addresses can break sign-in and setup. See [The public origin](/operator/configuration/#the-public-origin). ## nginx @@ -174,7 +174,7 @@ chat.example.com { } ``` -Caddy trusts no proxy by default and takes the client address from the connection. Set the global `trusted_proxies` option when another proxy or a CDN sits in front of Caddy, or it records that hop as the client. Caddy appends its own peer to the end of the address list, and Fluxer reads the first entry. +Caddy trusts no proxy by default. If another proxy or CDN sits in front of it, configure Caddy's global `trusted_proxies` option and Fluxer's [trusted proxies](#trusted-proxies) so requests retain the real client address. ## Traefik @@ -380,67 +380,27 @@ Nginx Proxy Manager appends to `X-Forwarded-For`, so the visitor's own value arr ## What the single port routes -The instance rewrites paths before handing them to an upstream. Your proxy must not rewrite anything itself. +Forward every path and query string unchanged. The edge handles routing: -#### `/_health` +| Public path | Purpose | +| --- | --- | +| `/_health` | Edge health check | +| `/gateway`, `/gateway/*` | Gateway connections and health | +| `/api/*` | HTTP API | +| `/media/*` | Media and attachment delivery | +| `/livekit/*` | Voice signalling | +| `/admin`, `/admin/*` | Admin dashboard | +| `/web/*`, `/emoji/*`, `/libs/*`, `/avatars/*`, `/badges/*`, `/desktop/*`, `/embeds/*` | Static assets | +| `/.well-known/fluxer` | Instance discovery | +| `/.well-known/apple-app-site-association`, `/apple-app-site-association` | Apple app association | +| `/.well-known/assetlinks.json` | Android app association | +| `/version.json` | Client version metadata | -No upstream. Answered by the edge itself. - -#### `/gateway?v=1&encoding=json&compress=zstd-stream&stream=1` - -Goes to `gateway:8080` and reaches the upstream as `/?v=1&encoding=json&compress=zstd-stream&stream=1`. - -#### `/gateway/foo` - -Goes to `gateway:8080` and reaches the upstream as `/foo`. - -#### `/api/v1/users/@me` - -Goes to `api:8080` and reaches the upstream as `/v1/users/@me`. - -#### `/media/attachments/1/2/a.png` - -Goes to `media-proxy:8080` and reaches the upstream as `/attachments/1/2/a.png`. - -#### `/livekit/rtc/v1` - -Goes to `livekit:7880` and reaches the upstream as `/rtc/v1`. - -#### `/admin` - -Goes to `admin:8080` and reaches the upstream as `/`. - -#### `/admin/users` - -Goes to `admin:8080` and reaches the upstream as `/users`. - -#### Static asset paths - -`/web/*`, `/emoji/*`, `/libs/*`, `/avatars/*`, `/badges/*`, `/desktop/*` and `/embeds/*` go to `static-proxy:8080` unchanged. - -#### `/.well-known/fluxer` - -Goes to `api:8080` unchanged. - -#### Apple app site association - -`/.well-known/apple-app-site-association`, and `/apple-app-site-association` for older iOS, go to `app-proxy:8080` unchanged. - -#### `/.well-known/assetlinks.json` - -Goes to `app-proxy:8080` unchanged. - -#### `/version.json` - -Goes to `app-proxy:8080` unchanged. - -#### Anything else - -Goes to `app-proxy:8080` unchanged. +All other paths serve the web app. Pass the query string on `/gateway` through untouched. Clients always send `?v=`, `?encoding=`, `?compress=` and `?stream=`, and `1` is the only version the Gateway accepts. -Apple and Google fetch the association files at those fixed paths, for saved-password autofill in the iOS apps and link handling in the Android ones. The last entry already has them all, so a proxy that forwards `/` needs no extra rule. A proxy that forwards a named path allowlist has to list these paths along with everything else that entry covers. +Apple and Google require the association files at those fixed paths for saved-password autofill and app links. A proxy that forwards all paths needs no extra rules. Include them explicitly if you use a path allowlist. `/_metrics` on the API, Media Proxy, and Gateway, plus `/_health/ready`, `/_health/drain`, and `/_health/undrain` on the Gateway, are gated to loopback and are unreachable through any proxy. The probes that work through a proxy are `/_health`, `/api/_health`, `/gateway/_health`, and `/media/_health`. diff --git a/fluxer_docs/src/content/docs/operator/upgrading.mdx b/fluxer_docs/src/content/docs/operator/upgrading.mdx index ae70b2953..cc0bb109c 100644 --- a/fluxer_docs/src/content/docs/operator/upgrading.mdx +++ b/fluxer_docs/src/content/docs/operator/upgrading.mdx @@ -53,9 +53,9 @@ The refreshed `docker-compose.yml` renames the `caddy` service to `edge`, and th The rename also moves the `caddy-data` and `caddy-config` volumes to `edge-data` and `edge-config`, so the old two are left unused and the edge requests its certificate again on the first start. -`FLUXER_IMAGE_TAG` is the only line in `.env` that either upgrade mode rewrites once the instance holds every key the stack requires. An upgrade also writes a key the refreshed stack requires that `.env` does not hold at all, listed below, and it leaves every value already set as it is. +Upgrades change `FLUXER_IMAGE_TAG` and add missing required secrets. Existing values are preserved, except for the unusable upload-relay placeholder described below. -The refreshed `docker-compose.yml` requires `FLUXER_ERLANG_COOKIE`. An `.env` written by an earlier installer holds no such line, and `--update` writes one into `.env` before it reads anything, so an upgrade needs no edit for it. The value is 64 hex characters, which is what a fresh install writes. Until `.env` sets it, every Compose command against the stack stops with `set FLUXER_ERLANG_COOKIE in .env`. +The refreshed stack requires `FLUXER_ERLANG_COOKIE`. `--update` generates a missing value as 64 hex characters. Without it, Compose commands fail with `set FLUXER_ERLANG_COOKIE in .env`. To write it by hand, run the generator in a shell: @@ -63,9 +63,9 @@ To write it by hand, run the generator in a shell: printf '\nFLUXER_ERLANG_COOKIE=%s\n' "$(openssl rand -hex 32)" >> .env ``` -Pasting `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` into `.env` as text stores those characters as the value, because Compose reads a line literally and runs nothing in it. The leading newline keeps the key on its own line. An editor can leave the last line of `.env` without a newline, and the two would otherwise join. +Do not paste `FLUXER_ERLANG_COOKIE=$(openssl rand -hex 32)` into `.env`. Compose does not execute shell commands in that file. -`api` and `media-proxy` now refuse to start unless `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64` decodes to at least 32 bytes. `.env.example` has shipped it as `CHANGE_ME`, which decodes to 6 bytes, and nothing read it before, so an instance can be running today with the placeholder. `--update` replaces an absent or `CHANGE_ME` value with a generated one before it reads anything. Both services read the same value from one `.env` line. A `CHANGE_ME` value stops `api` at boot with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes`, and an empty one with `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required for the API`. +`api` and `media-proxy` require `FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64` to decode to at least 32 bytes. `--update` replaces a missing or `CHANGE_ME` value with a generated secret. Both services must use the same value. To write it by hand, run the generator in a shell the same way: @@ -75,11 +75,11 @@ printf '\nFLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=%s\n' "$(openssl rand -b ## The script is the reference -Every step above sits in the script beside a comment holding the command that does that step alone and the reason the step exists. Read [https://fluxer.dev/install.sh](https://fluxer.dev/install.sh), or [https://fluxer.dev/install.ps1](https://fluxer.dev/install.ps1) for Windows. +Read the [Linux and macOS installer](https://fluxer.dev/install.sh) or [Windows installer](https://fluxer.dev/install.ps1) before running it. ## Keep a local compose change -The Place step replaces all the stack files with the copies at the ref. An edit made directly in `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml` or the `Caddyfile` is gone once that step runs. The run prints one line for the whole step and never names the files it replaced. `--dry-run` names every one of them, as `changes`, `unchanged` or `is new`. +An upgrade replaces the stack files, including direct edits to `docker-compose.yml`, `docker-compose.proxy.yml`, `tunnel.compose.yml` or the `Caddyfile`. Use `--dry-run` to see which files will change. The supported way to hold a local choice is a separate file, listed in `COMPOSE_FILE` in `.env`: @@ -89,7 +89,7 @@ COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml:local.compose.yml Compose merges the files left to right, so `local.compose.yml` wins over the ones before it. An upgrade refreshes the stack files and nothing else, so a file outside that set survives every upgrade untouched. A record copies `.env` and those files, and no other file from the working directory. An override file is never backed up with them, so keep it wherever the rest of the configuration lives. -`.env` needs no such file. The only value either upgrade mode replaces there is `FLUXER_IMAGE_TAG`, and the Mint step adds a required key the file does not hold at all. +Keep environment settings in `.env`. Upgrades preserve them apart from the tag and required-secret changes described above. [Enable the overlay](/operator/reverse-proxy/#enable-the-overlay) has the overlay this is most often used for. [Docker labels](/operator/reverse-proxy/#docker-labels) has a worked third file. @@ -101,9 +101,7 @@ Compose merges the files left to right, so `local.compose.yml` wins over the one stat /srv/fluxer/docker-compose.proxy.yml: no such file or directory ``` -Every `docker compose` command stops there. An upgrade checks the list before it runs any of them, so it stops with a sentence naming the file and the `curl` that puts it back, and writes nothing. - -This happens on an instance older than the installer. `docker-compose.proxy.yml` and `tunnel.compose.yml` are files the upgrade downloads, so a `COMPOSE_FILE` line naming one of them in a directory that never held it fails long before the Fetch step that would have supplied it. +Every `docker compose` command stops there. This can affect older installations whose `.env` names an overlay that has not been downloaded. The installer reports the missing file and a command to restore it. Download the missing file at the ref the upgrade moves to, then run the upgrade: @@ -178,13 +176,9 @@ After: wss://chat.example.com/livekit ``` -Only the path is wrong. `docker-compose.yml` derives `https://chat.example.com/livekit` when `.env` sets no `FLUXER_LIVEKIT_URL`, and the client rewrites a leading `http` to `ws` itself, so `https` and `wss` both work. +Both `https` and `wss` work. Update `FLUXER_LIVEKIT_URL` in `.env` to the new path, or remove it to use the derived address, then run `docker compose up -d api`. -The address browsers dial is a column on a voice server row in the database. No upgrade migrates that row, and it is corrected in the places below. - -First `.env`. When it sets `FLUXER_LIVEKIT_URL`, put the new path in that line and run `docker compose up -d api`. `api` writes that value onto the default voice server row at every start, so a row corrected in the dashboard while `.env` still names the old path goes back to the old path on the next restart. Removing the line is also correct, and Compose then derives the URL from `FLUXER_PUBLIC_ORIGIN`, or from the scheme and the domain. - -Then the dashboard, at `https://chat.example.com/admin` under **Voice Servers**. The **Endpoint** field on that page holds the address. `api` writes one row only, the one in region `default` with server id `default-server-1`, and leaves every other row as it is. That row corrects itself on the first `api` start after `.env` is right, so the dashboard is for every other row. When that region or that server is absent, its log says `skipping config sync` and it writes nothing. A region added by hand, or one left from an older layout under another id, keeps the endpoint it already holds until that field is edited. +This updates the existing `default-server-1` server in the `default` region. For any other server, change **Endpoint** under **Voice Servers** in the admin dashboard. Correct `.env` first, or a later restart can restore the old address on the default server. Voice media is unaffected. It never went through the edge, and `7881/tcp` and `7882/udp` still reach the host directly. @@ -192,7 +186,7 @@ Voice media is unaffected. It never went through the edge, and `7881/tcp` and `7 Each upgrade writes one record directory, named `record-` and a UTC stamp, under `backups` or wherever `--backup-dir` points. It holds: -| Artifact | What it covers | +| Artefact | What it covers | | --- | --- | | `fluxer.dump` | Every account, message, guild and configuration row, in the Postgres custom format | | `seaweedfs-data.tgz` | Every upload, avatar, report and harvest | @@ -229,7 +223,7 @@ FLUXER_S3_FORCE_PATH_STYLE=false Pointing the stack elsewhere leaves the bundled service defined and running with nothing reading it. Take it out with an override file listed in `COMPOSE_FILE`, which [Keep a local compose change](#keep-a-local-compose-change) describes, rather than by editing `docker-compose.yml`, which the Place step replaces on every upgrade. -An upgrade backs up what the stack holds. The Dump step reaches into the `postgres` service and the Copy step reaches into the `seaweedfs` volume, so a stack that defines neither has neither step to run. The run says which one it skipped and goes on. Backing up a store outside the stack belongs to whoever runs it, and the record the upgrade writes holds no dump of it, so take one before upgrading if a rollback would need it. +The installer backs up only the bundled database and object store. Arrange separate backups for external stores before upgrading, and keep them with the release's backup record. ## Roll back @@ -244,11 +238,11 @@ On Windows it is `.\install.ps1 -Rollback`. It takes the newest record, puts bac A rollback never pulls, so it needs the old images still on the host. Run [Reclaim disk](#reclaim-disk) only once the upgrade is known good. -The database does not move. `api`, `worker`, `users-shard` and `messages-shard` apply schema work in place while they start, and an older image does not undo it. Across a release that changed the schema, the dump in the record is the only way back, and a rollback never puts it back for you. +A rollback does not undo database migrations or restore data. If the previous release requires the old schema, restore the database backup separately. ## Restore a backup -Both artifacts go back with the stack stopped, so nothing writes while they are replaced. Name your own record directory instead of the one below. +Restore both artefacts with the stack stopped, so nothing writes while they are replaced. Name your own record directory instead of the one below. The database restores from the custom-format dump: diff --git a/fluxer_docs/src/content/docs/snowflakes.md b/fluxer_docs/src/content/docs/snowflakes.md index 020e756fd..ba35c6667 100644 --- a/fluxer_docs/src/content/docs/snowflakes.md +++ b/fluxer_docs/src/content/docs/snowflakes.md @@ -28,9 +28,7 @@ A snowflake packs a timestamp, a worker ID, and a sequence into 64 bits. The wor The epoch is 1420070400000 milliseconds after the Unix epoch. Every Fluxer instance uses the same value. The lower 22 bits expose allocator details, so a client MUST NOT use them for routing or resource semantics. -One worker issues strictly increasing values. It advances the sequence for each identifier issued during the same millisecond, and it waits for the next millisecond once the sequence passes 4095. - -A worker whose clock moves backwards keeps issuing against the highest millisecond it has already used. An extracted timestamp can therefore fall later than the wall clock at the moment of issuing. +Treat the embedded timestamp as an approximate issue time, not proof that the resource already exists. ## Extracting a timestamp diff --git a/fluxer_docs/src/content/docs/topics/captcha.md b/fluxer_docs/src/content/docs/topics/captcha.md index 82788ea17..c3219fe61 100644 --- a/fluxer_docs/src/content/docs/topics/captcha.md +++ b/fluxer_docs/src/content/docs/topics/captcha.md @@ -12,8 +12,6 @@ An instance can require a CAPTCHA solution on a small set of abuse-sensitive ope The value `none` means the instance challenges no operation. A gated operation then proceeds with no CAPTCHA header. The values `hcaptcha` and `turnstile` name the provider whose widget a client renders. -Fluxer names a provider only while that provider holds both a site key and a secret key. An incomplete pair reports `none`, so a named provider means the instance enforces verification. - ## Gated operations The following operations verify a CAPTCHA while the instance enforces verification. @@ -30,19 +28,9 @@ The following operations verify a CAPTCHA while the instance enforces verificati Create private channel is gated only on the group direct message path, where the request body has a `recipients` member. A one-to-one direct message request omits the field and is never gated. -Create application, redeem gift, create private channel, and add group direct message recipient reject an unauthenticated request before Fluxer reads the CAPTCHA. The authentication operations accept a request with no credential. When the instance enforces single sign-on, each of them returns 403 `SSO_REQUIRED` before Fluxer reads the CAPTCHA. - ## Exemption -The exemptions below skip the challenge. Fluxer tests both before it reads the token. A request that passes either one proceeds as though the instance had no provider configured. - -The instance account policy grants the `captcha_exempt` capability to a contact address. A policy rule matches the address itself or the domain it belongs to, so one grant can cover a whole domain. Fluxer tests the capability against the resolved account's email address alone. An unauthenticated request never matches this exemption. - -The other exemption is the `APP_STORE_REVIEWER` user flag. Fluxer tests it against the resolved account, then against the account an `email` member of the request body resolves to. The body check parses the request body as JSON and reads a string `email` member, and a body that is absent, is not JSON, or is not a JSON object yields no address. That check exempts a login or a registration attempt before any account is resolved. - -Both exemptions run before request validation on the authentication operations, on create application, and on redeem gift. Create private channel and add group direct message recipient validate the request first, so an invalid request is rejected before any exemption is tested. - -No exemption is visible in an API response. A client cannot predict one and handles a challenge on every gated operation. +Instance policy can exempt a request. Exemptions are not advertised, so clients must handle a challenge on every gated operation. ## Request headers @@ -57,11 +45,9 @@ No exemption is visible in an API response. A client cannot predict one and hand ## The retry handshake -A client that has never been challenged sends the gated request without any CAPTCHA header. When the instance enforces verification and no exemption applies, that request fails with 400 `CAPTCHA_REQUIRED`. +Send the request without CAPTCHA headers. On 400 `CAPTCHA_REQUIRED`, obtain a solution through the selected provider's widget using its advertised site key. Retry the same request with `X-Captcha-Token` set to the solution and, optionally, `X-Captcha-Type` set to the provider. -The client then renders the selected provider's widget with its advertised site key, obtains a solution, and repeats the identical request with `X-Captcha-Token` set to the solution. It can send `X-Captcha-Type` to state which provider produced the solution. - -A retry succeeds when the provider accepts the solution and fails with 400 `INVALID_CAPTCHA` when it does not. +An accepted solution allows the operation to proceed. A rejected solution returns 400 `INVALID_CAPTCHA`. :::caution[A solution is single-use] The provider treats an already redeemed solution as invalid. A client obtains a new solution before retrying after `INVALID_CAPTCHA` and MUST NOT replay the previous `X-Captcha-Token` value. @@ -69,9 +55,7 @@ The provider treats an already redeemed solution as invalid. A client obtains a ## Provider verification -Fluxer submits the solution to the selected provider's verification endpoint over HTTPS with a 10-second deadline. It sends the caller's client IP address alongside the solution and omits it when the request resolves none. - -Any outcome other than a successful provider verdict answers 400 `INVALID_CAPTCHA`. That covers an unsuccessful verdict, a non-2xx provider status, an unparseable provider payload, the 10-second timeout, and any transport failure. A rejected solution is therefore never distinguishable from an unreachable provider. +A rejected solution or unavailable provider returns 400 `INVALID_CAPTCHA`. The response does not distinguish between these causes. ## Error codes diff --git a/fluxer_docs/src/content/docs/topics/locales.md b/fluxer_docs/src/content/docs/topics/locales.md index ea041cf76..dde101a9e 100644 --- a/fluxer_docs/src/content/docs/topics/locales.md +++ b/fluxer_docs/src/content/docs/topics/locales.md @@ -53,17 +53,13 @@ The registry below is the complete set for every Fluxer surface. Wherever Fluxer ## Negotiation -Fluxer resolves the response locale once for each request. When a request resolves an authenticated user with a stored locale, Fluxer takes that locale. Every other request negotiates the `Accept-Language` header against the [supported locale registry](#supported-locales), and `en-US` is the result whenever negotiation selects no registry value. +An authenticated user's saved locale takes precedence over `Accept-Language`. Otherwise, Fluxer negotiates that header against the [supported locales](#supported-locales), falling back to `en-US` when none matches. -An account created by password registration stores the locale negotiated from its own registration request, so the `Accept-Language` header on that request sets the stored value. An account created through single sign-on stores no account locale. Its [user settings](/http-api/users/settings/) locale reads `en-US`, and its requests negotiate `Accept-Language` until the locale setting is changed. +Set the account locale through [user settings](/http-api/users/settings/) to make the choice persistent. -Fluxer splits the header on commas. It trims each member and then splits it on semicolons. The text before the first semicolon is the language range, and Fluxer reads a `q=` weight from only the first parameter after it. A member with no readable `q=` value has weight 1. Fluxer orders the members by descending weight, and members of equal weight keep their header order. +Matching ignores case and accepts underscores in place of hyphens. Exact supported tags and the aliases `en` and `sv` take precedence over regional fallbacks. Header quality weights order candidates within each group, with header order breaking ties. -Fluxer then runs the passes below over that ordered list. - -The first pass takes the earliest member whose range names a registry value exactly. Fluxer trims the range, replaces every underscore with a hyphen, and lowercases it before comparing, so `EN-GB` and `en_gb` both name `en-GB`. The bare tags `en` and `sv` are registered aliases for `en-US` and `sv-SE` and match in this pass. - -The second pass runs only when the first selects nothing. It reduces each member in the same order to its language subtag. A language subtag with a declared preference selects that value. The declared preferences are: +Unsupported regional tags can fall back to these defaults: | Language subtag | Selected locale | | --- | --- | @@ -73,9 +69,7 @@ The second pass runs only when the first selects nothing. It reduces each member | `zh` | `zh-CN` | | `sv` | `sv-SE` | -Under those preferences, `en-AU` selects `en-US` and `pt-PT` selects `pt-BR`. A language subtag without a declared preference selects the first registry value whose tag begins with that subtag and a hyphen. - -Every registry tag with a hyphen begins with one of those subtags, so `de-AT` and `xx-YY` both select nothing. A weight of 0 orders a member last, and that member can still be selected. A range of `*` matches no registry value. +For example, `en-AU` selects `en-US` and `pt-PT` selects `pt-BR`. A weight of 0 does not exclude a language, and `*` matches no locale. Send an explicit supported tag for a predictable result. The resolved locale selects the localised `message` in an [error response](/http-api/#error-response) and in each element of a validation `errors` array. diff --git a/fluxer_docs/src/content/docs/topics/rate-limits.md b/fluxer_docs/src/content/docs/topics/rate-limits.md index 7b96e720e..c5bb1a744 100644 --- a/fluxer_docs/src/content/docs/topics/rate-limits.md +++ b/fluxer_docs/src/content/docs/topics/rate-limits.md @@ -24,8 +24,6 @@ The global window is one second. The default allowance is 50 requests per second Some operations enforce a further limit inside the handler. `RATE_LIMIT_BYPASS` exempts an account from none of them. [Limits enforced inside a handler](#limits-enforced-inside-a-handler) has the complete set. -A route bucket is consumed only after the global bucket admits the request. - :::note[An allowance drains continuously] Every bucket is a leaky bucket. It admits at most the declared limit at once and refills continuously at that limit for each declared window, so a client that exhausts an allowance can send again as soon as enough of it has drained. ::: @@ -35,12 +33,10 @@ Every bucket is a leaky bucket. It admits at most the declared limit at once and ::: :::note[A 429 can hide a 401 or 403] -A route's rate limit middleware normally runs before its authentication policy, so an over-allowance request returns 429 `RATE_LIMITED` where the same request inside its allowance would return 401 or 403. Fluxer resolves the credential before either check, and both buckets are keyed by the authenticated account whenever one resolves. +A rate-limited request can return 429 even if its credentials would otherwise be rejected with 401 or 403. ::: -The routes below charge a second bucket. [Create private channel](/http-api/users/private-channels/#create-private-channel) and [Add group direct message recipient](/http-api/channels/#add-group-direct-message-recipient) evaluate that second bucket after the authentication policy and the request validation, so an unauthenticated or malformed request is refused before it is consumed. Create private channel consumes `user:group_dm:create` only when the validated body supplies `recipients`. A one-to-one direct message create reaches no second bucket. - -[Delete guild emoji](/http-api/guild-emojis/#delete-guild-emoji) and [Delete guild sticker](/http-api/guild-stickers/#delete-guild-sticker) declare both of their buckets ahead of the authentication policy and the request validation, so an unauthenticated or malformed request consumes the second bucket too. The `guild:emoji:delete:daily::guild_id` and `guild:sticker:delete:daily::guild_id` buckets draw on the global allowance, and one delete request evaluates it twice. +Some operations have additional allowances, including group direct message creation, adding group recipients, and deleting guild emoji or stickers. Each operation lists its limits. Rejected requests can still consume an allowance. :::caution[A global denial revokes a user session] When the global bucket denies a request authenticated by a non-bot account's user session token, Fluxer revokes that token before writing the 429. The client must authenticate again. A bot token, an OAuth2 access token, and an Admin API key are never revoked this way, and a route bucket denial never revokes a credential. @@ -121,12 +117,12 @@ These headers describe a rate limit decision. An operation that answers 429 retu 5 Rounded to millisecond precision with trailing zeros removed on a successful response, and emitted as the exact computed decimal on a denial -6 The leading 16 hexadecimal characters of the SHA-256 digest of the route's declared bucket name before any path parameter is substituted, so it identifies the route and never the caller +6 An opaque identifier for the route's bucket, not the caller A 429 from a limit enforced inside a handler has the other route headers and no `X-RateLimit-Bucket`. -:::note[A browser client reads none of these headers] -The [cross-origin policy](/http-api/#cross-origin-requests) exposes only `X-Fluxer-Version`, so a script running on an allowed origin observes the 429 status and the response body, and no header. +:::note[Cross-origin clients cannot read rate-limit headers] +The [cross-origin policy](/http-api/#cross-origin-requests) does not expose these headers. Use the response body's `retry_after` value. ::: ## Limits enforced inside a handler @@ -164,7 +160,7 @@ The 400 shape has no `retry_after` member, no `X-RateLimit-*` header, and no `Re | [Send phone verification](/http-api/users/phone-verification/#send-phone-verification) | 3 per 5 days, keyed by the submitted number | `PHONE_RATE_LIMIT_EXCEEDED` | | [Resend IP authorisation](/http-api/authentication/#resend-ip-authorisation) | Nothing in the first 30 seconds after the ticket was issued, keyed by the authorisation ticket | `IP_AUTHORIZATION_RESEND_COOLDOWN` | -Fluxer stores a further cooldown when the SMS provider itself throttles a send. The provider names the account, the number, or both, and a send inside that cooldown reports `PHONE_RATE_LIMIT_EXCEEDED` with the ordinary denial body and the remaining delay. +SMS provider throttling can impose an additional cooldown. It returns `PHONE_RATE_LIMIT_EXCEEDED` with the remaining delay. The Resend IP authorisation cooldown has no `X-RateLimit-*` header. It has a `Retry-After` header in whole seconds, and the body reports that delay again as a top-level `resend_available_in` and `retry_after`. A second resend on one ticket returns 400 `IP_AUTHORIZATION_RESEND_LIMIT_EXCEEDED`. The allowance never refills, and the ticket expires 15 minutes after it was issued. diff --git a/fluxer_docs/src/content/docs/topics/uploads.md b/fluxer_docs/src/content/docs/topics/uploads.md index f2188ad33..be03a640a 100644 --- a/fluxer_docs/src/content/docs/topics/uploads.md +++ b/fluxer_docs/src/content/docs/topics/uploads.md @@ -1,26 +1,25 @@ --- # SPDX-License-Identifier: AGPL-3.0-or-later title: Attachment uploads -description: The pre-upload plan, its modes, and how a client claims the result. +description: Upload attachments and claim them in a message. --- -Fluxer accepts an attachment inline as a `files[n]` part of a [multipart request](/http-api/#request-body-formats), or pre-uploaded before the message exists. The pre-upload operations and objects live on the [Messages resource](/http-api/messages/). +Send attachments as `files[n]` parts in a [multipart message](/http-api/#request-body-formats), or upload them before creating the message. Pre-uploading has four steps: -The server chooses the flow's mode from the declared byte count. A file of 10485760 bytes or less, the 10 MiB threshold, is planned as a singlepart upload and is finished as soon as its bytes are stored. A larger file is planned as a multipart upload and needs an explicit completion call. Both modes end the same way, by naming the resulting `upload_filename` in an ordinary message operation. +1. Request an upload plan. +2. Send the bytes to its upload URLs. +3. Complete the upload if it is multipart. +4. Include its `upload_filename` when creating or editing a message. :::caution[Pre-uploads can be switched off] -When a deployment disables them, the plan request and the completion request both answer 403 `FEATURE_TEMPORARILY_DISABLED` ahead of the channel, permission, and size checks, and the [instance features object](/http-api/instance/#instance-features-object) reports `presigned_attachment_uploads` false. A client falls back to the inline multipart path. +When [instance features](/http-api/instance/#instance-features-object) reports `presigned_attachment_uploads` false, use inline multipart uploads. Plan and completion requests return 403 `FEATURE_TEMPORARILY_DISABLED`. ::: ## Requesting an upload plan -[Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) takes from 1 through 10 attachment declarations. Each is a client-chosen `id`, a `filename`, an exact `file_size` in bytes, and a `content_type`. A user session credential and a bot token are accepted, and an OAuth2 bearer credential is rejected with 403 `ACCESS_DENIED`. +[Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) accepts up to 10 files. Declare each file's ID, name, exact byte count and content type. The endpoint reference defines the fields, permissions and file size limits. -The channel must support messages, and any other channel type returns 400 `CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL`. A guild channel also requires [SEND_MESSAGES](/http-api/permissions/) and [ATTACH_FILES](/http-api/permissions/), returning 403 `MISSING_PERMISSIONS` otherwise, and a caller under a communication timeout receives 403 `COMMUNICATION_DISABLED`. - -Fluxer then checks every declared size on its own against the `max_attachment_file_size` limit resolved for the caller and the guild. A size above it returns 400 `FILE_SIZE_TOO_LARGE` with the resolved ceiling before anything is planned. That limit defaults to 26214400 bytes, the 25 MiB non-premium allowance, and to 524288000 bytes, the 500 MiB premium allowance. A bot credential is clamped to 52428800 bytes, the 50 MiB bot ceiling, even when the resolved limit is higher. - -The response returns one entry for each declaration, in request order, discriminated by `upload_mode`. Every entry has the `id` from the request, the `filename`, the opaque `upload_filename` to claim later, the `file_size`, and the `content_type` the server derived from the filename. The issued capability authorises that derived media type, and the stored object has it. +Keep the returned plan, including its `upload_filename`. Use the returned `content_type`, which may differ from the declared value. The `upload_mode` determines the next steps. ### Upload modes @@ -35,19 +34,15 @@ The response returns one entry for each declaration, in request order, discrimin ## Part geometry -The multipart `part_size` is the declared file size divided by 20, rounded up to a whole mebibyte, and never below 10485760 bytes. Every part is exactly `part_size` bytes except the last, which is the remainder. - -A plan is bounded at 10,000 parts. One that would need more returns 400 `FILE_SIZE_TOO_LARGE` before the storage multipart upload is opened. The resolved file size limit is the only binding constraint. +Use the `part_size` and `parts` returned in the upload plan. Every part must contain exactly `part_size` bytes except the last, which contains the remainder. Do not calculate a different part layout. ## Transferring the bytes -Each `upload_url` is a `PUT` target with its own authorisation in its query string. A direct storage URL has the object store's own signature and a relay URL has the signed relay capability in its `t` parameter, so neither shape reads an `Authorization` header. +Send `PUT` requests to the returned `upload_url` values without an `Authorization` header. The URLs can target storage or the [upload relay](/media-proxy/upload-relay/). Do not rewrite their paths or query strings. -The direct storage capability signs the exact byte count, so a `PUT` of any other length is rejected. A relay capability bounds the length at the same value and answers 413 above it, and the relay applies its own body ceiling, 500 MiB by default, on top of that. Without a declared `Content-Length`, the relay spools the body to the smaller of the bounds and answers 413 past it. The relay answers 401 when the capability is missing, malformed, or expired. Either way, a client sends exactly the authorised byte count. +Send exactly the declared byte count. The relay returns 413 for an oversized body and 401 for a missing, invalid, or expired capability. Its own body limit also applies, with a default of 500 MiB. -A singlepart transfer sends the whole file and must send the entry's `content_type` as its `Content-Type` header. The relay takes the media type from its capability and ignores the header. A multipart part transfer sends only that part's bytes and has no signed media type. - -The instance decides per request whether to relay. It resolves the caller's country from the client IP address and issues a direct storage URL only when that country is on the deployment's direct-upload list. Every other caller receives a URL on the [upload relay](/media-proxy/upload-relay/), including one whose country cannot be resolved. A client treats both shapes the same way and MUST NOT parse, rewrite, or reorder the query string of either. +A singlepart transfer sends the whole file with the entry's `content_type` as its `Content-Type` header. A multipart transfer sends each part separately. ### Capability lifetimes @@ -65,26 +60,13 @@ An issued upload URL authorises writing one object or one part. A client treats ## Completing a multipart upload -[Complete attachment upload](/http-api/messages/#complete-attachment-upload) finishes from 1 through 10 multipart uploads. Each entry names the `upload_filename` and the `upload_id` from the plan. The caller sends no part list and no entity tags. The server lists the parts the storage backend has already accepted, sorts them by part number, and assembles them in that order. +[Complete attachment upload](/http-api/messages/#complete-attachment-upload) finishes from 1 through 10 multipart uploads. Send the `upload_filename` and `upload_id` from each plan after all its parts succeed. Do not send a part list or entity tags. -The channel, permission, and communication checks of the plan request run again, and the operation answers 403 `FEATURE_TEMPORARILY_DISABLED` when pre-uploads are switched off. - -The failures below are reported as [validation error object](/http-api/#validation-error-object) entries on a 400 `INVALID_FORM_BODY` response. - -| Code | Path | Condition | -| --- | --- | --- | -| UPLOADED_ATTACHMENT_NOT_FOUND1 | `uploads.{index}.upload_filename` | The key is not a pending multipart upload of the authenticated identity in this channel | -| NO_UPLOADED_PARTS_TO_FINALIZE2 | `parts` | The storage backend holds no part for the upload | - -1 An upload another identity planned, one planned for another channel, one planned as singlepart, and one a message has already consumed are all reported this way, so a caller cannot probe another identity's upload state - -2 Fluxer aborts the multipart upload before it returns the error - -Fluxer then sums the listed part sizes. A total above the resolved file size limit aborts the upload and returns 400 `FILE_SIZE_TOO_LARGE`. A storage failure during assembly aborts it as well. An aborted upload discards its parts, and its `upload_filename` can never be claimed, so a client requests a new plan for the file. +Completion checks permissions and file size limits again. See the endpoint's [response table](/http-api/messages/#complete-attachment-upload) for errors. If completion aborts the upload, request a new plan and upload the file again. ## Claiming the upload -Nothing before this step creates a message, changes a channel, or emits a Gateway Dispatch. The attachment exists only once a [Create message](/http-api/messages/#create-message) or [Modify message](/http-api/messages/#modify-message) request has the `upload_filename` in a [pre-uploaded attachment](/http-api/messages/#pre-uploaded-attachment-object) entry. +Include the `upload_filename` in a [pre-uploaded attachment](/http-api/messages/#pre-uploaded-attachment-object) when calling [Create message](/http-api/messages/#create-message) or [Modify message](/http-api/messages/#modify-message). Uploading alone does not create a message or emit a Gateway event. An upload is bound to the identity and the channel that planned it, and a key is single use. A key the authenticated identity does not own, a key planned for another channel, and a key an attachment has already consumed each return 400 `INVALID_FORM_BODY` with `UPLOADED_ATTACHMENT_NOT_FOUND` on `attachments.{index}.upload_filename`. Where the object is absent from storage, which is what an untransferred plan leaves behind, the claim returns the same status with `FILE_NOT_FOUND` on the same path. @@ -94,28 +76,22 @@ Once a capability expires it cannot be refreshed, and a plan cannot be re-read. ## Stream previews -A stream preview is a still image attached to one voice connection, the one that publishes the stream. It does not use the attachment flow. The [stream key](/http-api/streams/#stream-key) names the scope, the channel, and that connection ID. Every preview operation is user-only. - -Uploading the image, issuing an upload capability, and deleting the preview each require the caller to hold a voice state in that channel whose connection ID matches the key. In a guild channel they require the [STREAM](/http-api/permissions/) permission as well. Reading the preview requires only access to the channel, and [CONNECT](/http-api/permissions/) in a guild channel, so any member who can join can read it. +A stream preview is a JPEG attached to a voice connection. It uses the user-only [Streams API](/http-api/streams/), not the attachment flow. Follow its [access rules](/http-api/streams/#access-rules) to read or change a preview. :::note[Treat a stored preview as publisher-asserted] -A voice state has no flag the check reads, so a caller can upload and read a preview for a connection that is publishing nothing. +A preview does not prove that its connection is currently publishing a stream. ::: -[Upload stream preview](/http-api/streams/#upload-stream-preview) posts the image inline. Its JSON body has the `channel_id` the connection is in, the base64-encoded image in `thumbnail` of 1 through 2000000 characters, and an optional `content_type` of 1 through 64 characters. A `thumbnail` that is not canonical base64 returns 400 `INVALID_STREAM_THUMBNAIL_PAYLOAD`. Canonical base64 here means the standard alphabet, a length that is a multiple of four, at most two trailing `=`, and decoded bytes that re-encode to the string the request sent. +Use [Upload stream preview](/http-api/streams/#upload-stream-preview) to send the image as base64 in JSON. Canonical base64 uses the standard alphabet, a length divisible by four and at most two trailing `=` characters. Decoding and re-encoding must produce the same string. Invalid encoding returns 400 `INVALID_STREAM_THUMBNAIL_PAYLOAD`. -A `content_type` containing `jpeg` or `jpg` in any case is accepted without inspecting the bytes. Every other value, an absent field included, is accepted only when the decoded bytes begin with `FF D8` and end with `FF D9`. A failure returns 400 `PREVIEW_MUST_BE_JPEG`. Decoded bytes above 1000000 return 400 `FILE_SIZE_TOO_LARGE`. +Alternatively, [request an upload URL](/http-api/streams/#create-stream-preview-upload-url) and send the JPEG with `PUT`. Use the returned `content_type`, respect `max_bytes` and upload before `expires_at`. The URL can be reused until it expires. -The operation answers 204 once the image is accepted. Fluxer absorbs a transient storage failure, so a 204 confirms acceptance alone. +Send a valid JPEG of at most 1000000 bytes. [Get stream preview](/http-api/streams/#get-stream-preview) reads it, and [Delete stream preview](/http-api/streams/#delete-stream-preview) removes it. -[Create stream preview upload URL](/http-api/streams/#create-stream-preview-upload-url) issues a reusable `PUT` capability for the same purpose. Its `content_type` must contain `jpeg` or `jpg` in any case, and every other value returns 400 `PREVIEW_MUST_BE_JPEG`. It answers with `upload_url`, `method` fixed to `PUT`, the `content_type` the client sends, `expires_at`, `expires_in`, and `max_bytes`, which is always 1000000. A direct storage capability lasts one day and a relay capability lasts the relay token lifetime, so `expires_in` differs between the shapes. The capability writes the same object every time it is used, and a publisher refreshes the thumbnail without asking for a new URL. - -Nothing inspects the bytes written through that capability. A relay capability still refuses a declared length above `max_bytes` with 413, and a direct storage capability enforces nothing beyond its signed media type. A publisher encodes a valid JPEG of at most 1000000 bytes itself. - -[Get stream preview](/http-api/streams/#get-stream-preview) returns the current image bytes with `Cache-Control: no-store, private`, and [Delete stream preview](/http-api/streams/#delete-stream-preview) removes it. The stored preview record expires one day after the inline upload that wrote it, or one day after the call that issued the capability. Using a capability again does not extend it. A read past that point answers an empty 404 even when the object is still in storage. +A preview expires one day after an inline upload or the request that issued its upload URL. Reusing the URL does not extend the preview's lifetime. An expired preview returns an empty 404. ## Failures -Every HTTP API operation in this flow returns the ordinary [error response](/http-api/#error-response) envelope. A relay request answers with the plain-text [media error response](/media-proxy/responses-and-limits/#media-error-response) that the [Media Proxy API](/media-proxy/overview/) defines. A direct storage URL returns whatever the storage backend returns, in that backend's own format. +API requests return the standard [error response](/http-api/#error-response). Relay requests return [plain-text media errors](/media-proxy/responses-and-limits/#media-error-response). Direct storage errors use the storage provider's format. -The relay has no request-count rate limit. [Request attachment upload URLs](/http-api/messages/#request-attachment-upload-urls) and [Complete attachment upload](/http-api/messages/#complete-attachment-upload) share the `channel:attachment:upload::channel_id` bucket, which permits 10 requests per 10 seconds for each authenticated identity and channel ID. Each stream preview operation declares its own bucket. [Rate limits](/topics/rate-limits/) defines both. +Each API endpoint documents its [rate limit](/topics/rate-limits/). The relay has no request-count rate limit. diff --git a/fluxer_docs/src/content/docs/voice/index.md b/fluxer_docs/src/content/docs/voice/index.md index 7b0832f72..7f29d198e 100644 --- a/fluxer_docs/src/content/docs/voice/index.md +++ b/fluxer_docs/src/content/docs/voice/index.md @@ -8,10 +8,6 @@ Fluxer runs voice over LiveKit. The [main Gateway](/gateway/overview/) places a [Client commands](/gateway/commands/) and [Gateway events](/gateway/events/) define the placement protocol. -:::note[A voice rewrite is in progress] -Every statement on this page is what an instance serves today, and a later release can change it. Re-read the page after an instance upgrade. -::: - ## Voice surfaces | Surface | What it is | Reference | @@ -133,11 +129,7 @@ A member whose `communication_disabled_until` is still in the future is refused `rtc_region` is written by [Modify channel](/http-api/channels/#modify-channel) and requires UPDATE_RTC_REGION. A null value selects automatic routing, and so does a stored value the placing account cannot reach. -The first placement in the channel pins one voice server for it, and every later placement inherits that pinned server whatever its own coordinates are. A placement that finds no usable pin takes the accessible server nearest to the `latitude` and `longitude` the placement command supplied. Where the command supplied no usable coordinates, the placement falls back to the deployment's default region, and then to the first accessible region. - -A voice server can have a soft connection limit. A placement that has to choose a server prefers the servers below their limit and uses one that is at or above its limit only when no other server can take the placement. [Soft connection limits](/admin-api/voice/#soft-connection-limits) describes the rule in full. - -The pin drops when the channel's `rtc_region` changes, when a call changes region, when the pinned server stops being accessible, or when the media server reports the room finished. That last case also disconnects every connection in a guild voice channel. +Automatic routing selects an available server, using the supplied `latitude` and `longitude` when possible. Participants in the same channel share a server. Always use the endpoint returned in [Voice Server Update](/gateway/events/#voice-server-update). The literal `automatic` is not a channel region. Only the `region` field of [Modify call region](/http-api/calls/#modify-call-region) accepts it, as a synonym for null. @@ -173,7 +165,7 @@ The [Streams resource](/http-api/streams/) owns the operations addressed by that On an instance that is not self-hosted, Fluxer mutes a camera or screen share track above 1280x720 from a member without the higher video quality entitlement, and removes that source from the connection's grant. The voice connection stays up. ::: -Fluxer removes screen share audio from the grant together with screen share, and removes camera on its own. The member keeps publishing its remaining sources, and a connection whose grant has no source left may publish nothing. A track published without a `sid` is neither muted nor revoked. +Losing screen share permission also stops its audio. Other permitted track sources remain available. ## Entrance sounds diff --git a/fluxer_gateway/src/utils/backoff_utils.erl b/fluxer_gateway/src/utils/backoff_utils.erl index 8b1582864..4c4d66768 100644 --- a/fluxer_gateway/src/utils/backoff_utils.erl +++ b/fluxer_gateway/src/utils/backoff_utils.erl @@ -11,26 +11,28 @@ ]). -define(MAX_BACKOFF_EXPONENT, 32). +-define(BASE_BACKOFF_MS, 1000). +-define(DEFAULT_MAX_BACKOFF_MS, 30000). -spec calculate(non_neg_integer()) -> non_neg_integer(). calculate(Attempt) -> - calculate(Attempt, 30000). + calculate(Attempt, ?DEFAULT_MAX_BACKOFF_MS). -spec calculate(non_neg_integer(), pos_integer()) -> non_neg_integer(). calculate(Attempt, MaxMs) -> - Exponent = min(cap_exponent(Attempt), ?MAX_BACKOFF_EXPONENT), - BackoffMs = round(1000 * math:pow(2, Exponent)), + Exponent = cap_exponent(Attempt), + BackoffMs = ?BASE_BACKOFF_MS bsl Exponent, min(BackoffMs, MaxMs). -spec cap_exponent(non_neg_integer()) -> non_neg_integer(). cap_exponent(Attempt) when is_integer(Attempt), Attempt >= 0 -> - Attempt; + min(Attempt, ?MAX_BACKOFF_EXPONENT); cap_exponent(_) -> 0. -spec calculate_with_jitter(non_neg_integer()) -> non_neg_integer(). calculate_with_jitter(Attempt) -> - calculate_with_jitter(Attempt, 30000). + calculate_with_jitter(Attempt, ?DEFAULT_MAX_BACKOFF_MS). -spec calculate_with_jitter(non_neg_integer(), pos_integer()) -> non_neg_integer(). calculate_with_jitter(Attempt, MaxMs) -> diff --git a/fluxer_gateway/src/utils/custom_status_validation.erl b/fluxer_gateway/src/utils/custom_status_validation.erl index 6dccbe151..69f8bd423 100644 --- a/fluxer_gateway/src/utils/custom_status_validation.erl +++ b/fluxer_gateway/src/utils/custom_status_validation.erl @@ -22,20 +22,8 @@ build_request(UserId, CustomStatus) -> -spec build_custom_status_payload(map()) -> map(). build_custom_status_payload(CustomStatus) -> - Fields = [ - {<<"text">>, maps:get(<<"text">>, CustomStatus, undefined)}, - {<<"expires_at">>, maps:get(<<"expires_at">>, CustomStatus, undefined)}, - {<<"emoji_id">>, maps:get(<<"emoji_id">>, CustomStatus, undefined)}, - {<<"emoji_name">>, maps:get(<<"emoji_name">>, CustomStatus, undefined)} - ], - lists:foldl( - fun - ({_Key, undefined}, Acc) -> Acc; - ({Key, Value}, Acc) -> Acc#{Key => Value} - end, - #{}, - Fields - ). + Fields = [<<"text">>, <<"expires_at">>, <<"emoji_id">>, <<"emoji_name">>], + maps:filter(fun(_Key, Value) -> Value =/= undefined end, maps:with(Fields, CustomStatus)). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). diff --git a/fluxer_gateway/src/utils/user_utils.erl b/fluxer_gateway/src/utils/user_utils.erl index 4c803478e..a11a48b16 100644 --- a/fluxer_gateway/src/utils/user_utils.erl +++ b/fluxer_gateway/src/utils/user_utils.erl @@ -22,13 +22,11 @@ partial_user_fields() -> -spec normalize_user(map() | term()) -> map(). normalize_user(User) when is_map(User) -> - CleanPairs = - lists:foldl( - fun(Key, Acc) -> add_normalized_field(Key, User, Acc) end, - [], - partial_user_fields() - ), - maps:from_list(lists:reverse(CleanPairs)); + lists:foldl( + fun(Key, Acc) -> add_normalized_field(Key, User, Acc) end, + #{}, + partial_user_fields() + ); normalize_user(_) -> #{}. @@ -46,19 +44,18 @@ normalize_field(<<"mention_flags">>, Value) -> normalize_field(_Key, Value) -> Value. --spec add_normalized_field(binary(), map(), [{binary(), term()}]) -> [{binary(), term()}]. +-spec add_normalized_field(binary(), map(), map()) -> map(). add_normalized_field(Key, User, Acc) -> case maps:get(Key, User, undefined) of undefined -> Acc; Value -> maybe_add_normalized_field(Key, normalize_field(Key, Value), Acc) end. --spec maybe_add_normalized_field(binary(), term(), [{binary(), term()}]) -> - [{binary(), term()}]. +-spec maybe_add_normalized_field(binary(), term(), map()) -> map(). maybe_add_normalized_field(_Key, undefined, Acc) -> Acc; maybe_add_normalized_field(Key, Normalized, Acc) -> - [{Key, Normalized} | Acc]. + Acc#{Key => Normalized}. -ifdef(TEST). -include_lib("eunit/include/eunit.hrl").