@meith/web 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/app/(board)/notifications/preferences/page.tsx +15 -1
  2. package/app/(board)/unsubscribe/page.tsx +5 -3
  3. package/app/admin/api/marketplace/screenshot/route.ts +0 -8
  4. package/app/admin/users/mail/page.tsx +1 -1
  5. package/app/layout.tsx +1 -3
  6. package/app/manifest.ts +1 -3
  7. package/bin/forum-web.mjs +1 -182
  8. package/bin/forum-web.test.ts +0 -22
  9. package/next.config.mjs +14 -29
  10. package/package.json +47 -47
  11. package/proxy.ts +0 -14
  12. package/src/components/account/notification-forms.tsx +45 -0
  13. package/src/components/admin/admin-nav.tsx +0 -4
  14. package/src/components/admin/marketplace-listing.tsx +0 -9
  15. package/src/components/auth/passkey-client.ts +0 -5
  16. package/src/components/auth/register-form.tsx +18 -0
  17. package/src/components/content/composer-ids.ts +0 -1
  18. package/src/components/content/markdown-editor.tsx +0 -7
  19. package/src/components/shell/demo-banner.tsx +0 -8
  20. package/src/config.ts +0 -11
  21. package/src/server/admin-actions.ts +0 -5
  22. package/src/server/antispam.ts +27 -14
  23. package/src/server/auth-actions.ts +12 -1
  24. package/src/server/auth-config.ts +0 -11
  25. package/src/server/auth-events.ts +0 -5
  26. package/src/server/brand-assets.ts +2 -6
  27. package/src/server/container.ts +0 -8
  28. package/src/server/cookies.ts +0 -24
  29. package/src/server/demo-uploads.ts +0 -11
  30. package/src/server/demo.ts +0 -21
  31. package/src/server/federation-actions.ts +0 -6
  32. package/src/server/federation.ts +0 -7
  33. package/src/server/feed-routes.ts +0 -5
  34. package/src/server/i18n.ts +1 -3
  35. package/src/server/location-header.ts +0 -30
  36. package/src/server/mail-send.ts +0 -6
  37. package/src/server/marketplace-actions.ts +0 -6
  38. package/src/server/marketplace-admin.ts +0 -33
  39. package/src/server/navigation.ts +0 -5
  40. package/src/server/notification-actions.ts +25 -0
  41. package/src/server/onboarding.ts +0 -5
  42. package/src/server/plugin-admin-actions.ts +0 -11
  43. package/src/server/plugin-admin.ts +0 -3
  44. package/src/server/plugin-host.ts +15 -6
  45. package/src/server/plugin-pages.ts +3 -68
  46. package/src/server/plugin-routes.ts +1 -1
  47. package/src/server/plugin-runtime.ts +78 -0
  48. package/src/server/presence.ts +3 -34
  49. package/src/server/relations.ts +1 -3
  50. package/src/server/reply-core.ts +1 -0
  51. package/src/server/request-fingerprint.ts +0 -1
  52. package/src/server/search-page.ts +0 -4
  53. package/src/server/session-actions.ts +0 -1
  54. package/src/server/settings.ts +0 -11
  55. package/src/server/subscription-actions.ts +12 -4
  56. package/src/server/syndication.ts +0 -4
  57. package/src/server/theme-admin.ts +2 -6
  58. package/src/server/thread-core.ts +6 -1
  59. package/src/server/two-factor-actions.ts +0 -5
  60. package/src/server/two-factor.ts +0 -6
  61. package/src/server/upgrade-notice.ts +1 -1
  62. package/src/view/account-copy.ts +5 -0
  63. package/src/view/auth-copy.ts +2 -0
  64. package/src/view/editor-toolbar.ts +0 -1
  65. package/src/view/navigation.ts +0 -4
  66. package/src/view/notification-menu.ts +0 -2
  67. package/src/view/notifications.ts +1 -0
  68. package/src/view/pager.ts +0 -6
  69. package/src/view/panel-nav.ts +0 -1
  70. package/src/view/security-activity.ts +0 -5
  71. package/src/view/subscriptions.ts +1 -0
  72. package/src/view/two-factor.ts +0 -5
  73. package/src/view/usercp.ts +1 -3
@@ -6,6 +6,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
6
6
  import {
7
7
  markAllNotificationsReadAction,
8
8
  markNotificationReadAction,
9
+ saveMassMailOptInAction,
9
10
  saveNotificationPreferencesAction,
10
11
  } from '@/server/notification-actions'
11
12
 
@@ -155,3 +156,47 @@ export function NotificationPreferencesForm({
155
156
  </form>
156
157
  )
157
158
  }
159
+
160
+ export function AnnouncementsOptInForm({ optedIn, copy }: { optedIn: boolean; copy: Copy }) {
161
+ const [state, action] = useActionState(saveMassMailOptInAction, EMPTY_STATE)
162
+
163
+ return (
164
+ <form
165
+ action={action}
166
+ className="flex flex-col gap-4 rounded-lg border border-border bg-card p-5"
167
+ >
168
+ <FormError message={state.error} />
169
+
170
+ <fieldset className="flex flex-col gap-3">
171
+ <legend className="text-sm font-medium">
172
+ {fromCopy(copy, 'accountForm.announcements.legend')}
173
+ </legend>
174
+
175
+ <label className="flex items-start gap-3 text-sm">
176
+ <input
177
+ type="checkbox"
178
+ name="announcements"
179
+ defaultChecked={optedIn}
180
+ className={`mt-1 ${CHECKBOX}`}
181
+ />
182
+ <span>
183
+ <span className="font-medium">{fromCopy(copy, 'accountForm.announcements.title')}</span>
184
+ <span className="block text-muted-foreground">
185
+ {fromCopy(copy, 'accountForm.announcements.description')}
186
+ </span>
187
+ </span>
188
+ </label>
189
+ </fieldset>
190
+
191
+ <p className="text-xs text-muted-foreground">
192
+ {fromCopy(copy, 'accountForm.announcements.blurb')}
193
+ </p>
194
+
195
+ <div>
196
+ <button type="submit" className={BUTTON}>
197
+ {fromCopy(copy, 'accountForm.announcements.submit')}
198
+ </button>
199
+ </div>
200
+ </form>
201
+ )
202
+ }
@@ -8,10 +8,6 @@ import { ADMIN_OVERVIEW, adminNavWithPlugin, pluginKeyAt } from '@/view/admin-na
8
8
  import type { PanelNav, PanelSection } from '@/view/panel-nav'
9
9
  import { pluginNavChildren } from '@/view/plugin-panel'
10
10
 
11
- /**
12
- * A plugin may reorder, drop, or add a section here. A link it adds reaches a
13
- * page it declared and nothing else — the panel re-checks who is asking.
14
- */
15
11
  async function withPluginSections(nav: PanelNav, t: Awaited<ReturnType<typeof getTranslator>>) {
16
12
  const links = await filterView(
17
13
  'admin.navigation',
@@ -12,15 +12,6 @@ const TONE_CLASS: Readonly<Record<StatusTone, string>> = {
12
12
  destructive: 'bg-destructive/10 text-destructive',
13
13
  }
14
14
 
15
- /**
16
- * One catalog entry, plugin or theme alike — the row model already carries
17
- * everything both browse pages need, so this is the one place that renders
18
- * it. Every string here is either our own catalog copy or a value from
19
- * `MarketplaceListingRow`, which has already capped lengths and validated
20
- * URLs against the untrusted feed (see `apps/community/src/server/marketplace-admin.ts`);
21
- * nothing here uses `dangerouslySetInnerHTML`, so the feed's own text is
22
- * rendered as plain text no matter what it contains.
23
- */
24
15
  export function MarketplaceListingCard({
25
16
  listing,
26
17
  t,
@@ -105,11 +105,6 @@ export async function signInWithPasskey(next: string | undefined, copy: Copy): P
105
105
  return assertPasskey('authenticate', next, copy)
106
106
  }
107
107
 
108
- /**
109
- * The same exchange, against a sign-in that has already given its password.
110
- * The board scopes the challenge to that member's own credentials, so the
111
- * browser offers only the keys that could finish this particular sign-in.
112
- */
113
108
  export async function confirmWithPasskey(next: string | undefined, copy: Copy): Promise<string> {
114
109
  return assertPasskey('second-factor', next, copy)
115
110
  }
@@ -105,6 +105,24 @@ export function RegisterForm({
105
105
  </>
106
106
  )}
107
107
 
108
+ <label className="flex items-start gap-2 text-sm text-muted-foreground">
109
+ <input
110
+ type="checkbox"
111
+ name="announcements"
112
+ value="1"
113
+ defaultChecked={state.values?.announcements === '1'}
114
+ className="mt-0.5 size-4 rounded border-input accent-primary"
115
+ />
116
+ <span>
117
+ <span className="block text-foreground">
118
+ {fromCopy(copy, 'authForm.register.announcements')}
119
+ </span>
120
+ <span className="block text-xs">
121
+ {fromCopy(copy, 'authForm.register.announcementsHint')}
122
+ </span>
123
+ </span>
124
+ </label>
125
+
108
126
  {terms !== null && (
109
127
  <label className="flex items-start gap-2 text-sm text-muted-foreground">
110
128
  <input
@@ -1,4 +1,3 @@
1
- /** The default `id` a page's `EditorToolbar` and the composer's textarea agree on. */
2
1
  export const MESSAGE_TEXTAREA_ID = 'post-message'
3
2
 
4
3
  export function attachmentFieldId(textareaId: string): string {
@@ -84,13 +84,6 @@ export interface MarkdownEditorProps {
84
84
  readonly hint?: React.ReactNode
85
85
  readonly scope?: PreviewScope
86
86
  readonly attachTo?: AttachmentTarget | undefined
87
- /**
88
- * Whether the formatting buttons render inside this component (`'inline'`,
89
- * the default — every composer outside the post/reply/edit pages) or are
90
- * left to the page's `EditorToolbar` slot (`'external'`), which addresses
91
- * this textarea by `id` from a different part of the tree. Either way the
92
- * textarea works with none of them: the buttons are the enhancement.
93
- */
94
87
  readonly toolbar?: 'inline' | 'external' | undefined
95
88
  }
96
89
 
@@ -2,14 +2,6 @@ import { demoBannerModel } from '@/server/demo'
2
2
  import { getTranslator } from '@/server/i18n'
3
3
  import { splitAround } from '@/view/copy'
4
4
 
5
- /**
6
- * The strip at the top of a demo board.
7
- *
8
- * Rendered from the root layout rather than through a theme slot, on purpose:
9
- * the slot contract is frozen, and a visiting administrator can switch themes.
10
- * A banner a theme could decline to render is a banner that stops telling the
11
- * next visitor how to log in, on the first theme that has not heard of it.
12
- */
13
5
  export async function DemoBanner() {
14
6
  const banner = await demoBannerModel()
15
7
  if (banner === null) return null
package/src/config.ts CHANGED
@@ -1,13 +1,2 @@
1
- /**
2
- * `@meith/web/config` — the surface a board's own `community.config.ts`
3
- * builds against.
4
- *
5
- * A scaffolded board depends on `@meith/web` for the app itself, and imports
6
- * its board-time registry through this subpath rather than reaching into
7
- * `@meith/core` directly. That indirection is what lets `@meith/web` change
8
- * which package actually defines `defineForumConfig` without every board's
9
- * `community.config.ts` following along.
10
- */
11
-
12
1
  export type { ForumConfig, InstalledPlugin, InstalledTheme, MessageBundle } from '@meith/core'
13
2
  export { defineForumConfig } from '@meith/core'
@@ -103,11 +103,6 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
103
103
  redirect(target)
104
104
  }
105
105
 
106
- /**
107
- * The panel's door asks for the second factor as well as the password. The
108
- * panel can rewrite the whole board, so re-proving only the thing most likely
109
- * to have leaked would be re-proving the wrong one.
110
- */
111
106
  async function adminReauthenticationBucket(userId: number): Promise<string> {
112
107
  const prefix = await retainedIpPrefix()
113
108
  return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
@@ -16,7 +16,7 @@ import {
16
16
  type RateLimitOutcome,
17
17
  subjectFor,
18
18
  } from '@meith/antispam'
19
- import type { Actor, NumericGlobalPermission } from '@meith/authorization'
19
+ import type { Actor, NumericGlobalPermission, Target } from '@meith/authorization'
20
20
  import { env, logger } from '@meith/core'
21
21
  import { getDb, PostgresCaptchaQuestionRepository, PostgresRateLimitBucketStore } from '@meith/db'
22
22
  import type { SettingsSnapshot } from '@meith/settings'
@@ -280,29 +280,42 @@ export async function verifyChallenge(form: FormData): Promise<ChallengeVerdict>
280
280
  }
281
281
  }
282
282
 
283
+ const FIRST_POST_UNRESOLVED = 'could not resolve first-post moderation'
284
+
285
+ function bypassesModeration(actor: Actor, target: Target): boolean {
286
+ try {
287
+ return getContainer().authorizer.can(actor, 'content.viewUnapproved', target)
288
+ } catch (error) {
289
+ logger().warn({ err: String(error), stage: 'bypass', held: true }, FIRST_POST_UNRESOLVED)
290
+ return false
291
+ }
292
+ }
293
+
283
294
  export async function holdsNewMember(input: {
284
295
  readonly actor: Actor
285
296
  readonly postCount: number
297
+ readonly target: Target
286
298
  readonly settings?: SettingsSnapshot
287
299
  }): Promise<boolean> {
300
+ let threshold = 0
288
301
  try {
289
302
  const settings = input.settings ?? (await getSettings())
290
- const threshold = Number(settings.get('antispam.moderate_first_posts') ?? 0)
291
- if (threshold <= 0) return false
292
-
293
- const { authorizer } = getContainer()
294
- return holdsForReview(
295
- {
296
- userId: input.actor.userId,
297
- postCount: input.postCount,
298
- bypassesModeration: authorizer.can(input.actor, 'content.viewUnapproved'),
299
- },
300
- { threshold },
301
- )
303
+ threshold = Number(settings.get('antispam.moderate_first_posts') ?? 0)
302
304
  } catch (error) {
303
- logger().warn({ err: String(error) }, 'could not resolve first-post moderation')
305
+ logger().warn({ err: String(error), stage: 'threshold', held: false }, FIRST_POST_UNRESOLVED)
304
306
  return false
305
307
  }
308
+
309
+ if (threshold <= 0) return false
310
+
311
+ return holdsForReview(
312
+ {
313
+ userId: input.actor.userId,
314
+ postCount: input.postCount,
315
+ bypassesModeration: bypassesModeration(input.actor, input.target),
316
+ },
317
+ { threshold },
318
+ )
306
319
  }
307
320
 
308
321
  export function antispamAvailable(): boolean {
@@ -93,7 +93,13 @@ export async function registerAction(_prev: FormState, form: FormData): Promise<
93
93
  const email = field(form, 'email')
94
94
  const password = field(form, 'password')
95
95
  const accepted = field(form, 'terms') !== ''
96
- const values = { username, email, ...(accepted ? { terms: '1' } : {}) }
96
+ const announcements = field(form, 'announcements') !== ''
97
+ const values = {
98
+ username,
99
+ email,
100
+ ...(accepted ? { terms: '1' } : {}),
101
+ ...(announcements ? { announcements: '1' } : {}),
102
+ }
97
103
 
98
104
  const identity = await configuredIdentity()
99
105
 
@@ -142,6 +148,11 @@ export async function registerAction(_prev: FormState, form: FormData): Promise<
142
148
 
143
149
  if (fields !== null) await fields.applyRegistration(result.account.id, fieldValues)
144
150
 
151
+ const { memberSettings } = getContainer()
152
+ if (announcements && memberSettings !== null) {
153
+ await memberSettings.saveMassMailOptIn({ userId: result.account.id, optIn: true })
154
+ }
155
+
145
156
  if (result.verificationToken !== undefined) {
146
157
  verification = {
147
158
  token: result.verificationToken,
@@ -38,17 +38,6 @@ export async function boardSessionConfig(): Promise<BoardSessionConfig> {
38
38
  }
39
39
  }
40
40
 
41
- /**
42
- * Applied last, over the stored settings, because on a demo the stored settings
43
- * are whatever the last visitor left behind.
44
- *
45
- * The lockout exists to make guessing a password expensive. A demo's passwords
46
- * are printed on the page, so there is nothing left to guess — and the counter
47
- * is per account, which means one visitor mistyping `admin` five times locks the
48
- * published login for everyone else for a quarter of an hour. Not disabled
49
- * outright: this is still a login form on the open internet, and something
50
- * hammering it should still meet a wall.
51
- */
52
41
  function demoOverrides(config: AuthConfig): AuthConfig {
53
42
  if (!env.DEMO_MODE) return config
54
43
 
@@ -8,11 +8,6 @@ import { requestFingerprint } from './request-fingerprint'
8
8
 
9
9
  export const MEMBER_ACTIVITY_LIMIT = 20
10
10
 
11
- /**
12
- * Never lets the log stop the thing it is recording. A sign-in that worked and
13
- * went unrecorded is a gap in an audit trail; a sign-in refused because the
14
- * audit trail was unwritable is an outage.
15
- */
16
11
  export async function recordAuthEvent(input: {
17
12
  readonly userId: number | null
18
13
  readonly kind: AuthEventKind
@@ -89,15 +89,11 @@ export async function loadBrandInfo(): Promise<BrandInfo> {
89
89
  const settings = await getSettings()
90
90
  title = settings.get('board.name').trim() || BOARD_TITLE
91
91
  description = settings.get('board.description').trim()
92
- } catch {
93
- /* ignore */
94
- }
92
+ } catch {}
95
93
 
96
94
  try {
97
95
  tokens = (await getBoardThemeStyle()).defaultTokens
98
- } catch {
99
- /* ignore */
100
- }
96
+ } catch {}
101
97
 
102
98
  return {
103
99
  title,
@@ -308,14 +308,6 @@ function buildPostgres(onBypass: (e: BypassEvent) => void): Container {
308
308
  }
309
309
  }
310
310
 
311
- /**
312
- * The reset is registered here rather than in `buildSchedulerBundle` because
313
- * this is the only composition root with a cache to clear afterwards. The
314
- * worker's `NextCacheDriver` is a different process holding a different map, so
315
- * a reset it ran would leave this process serving the forum tree of a board
316
- * that no longer exists — for up to the tree's 60-second TTL, on the one page
317
- * every visitor lands on.
318
- */
319
311
  function withDemoReset(bundle: SchedulerBundle, db: ReturnType<typeof getDb>): SchedulerBundle {
320
312
  if (!env.DEMO_MODE) return bundle
321
313
 
@@ -3,20 +3,9 @@ export const REMEMBER_COOKIE = '__Host-fs_remember'
3
3
  export const DEV_SESSION_COOKIE = 'fs_session'
4
4
  export const DEV_REMEMBER_COOKIE = 'fs_remember'
5
5
 
6
- /**
7
- * Counts one reader once, and carries nothing else.
8
- *
9
- * An opaque random value, first-party, readable by nobody but this board. It
10
- * exists because "37 guests reading" is not derivable from a stateless request:
11
- * without something that comes back, every page view is a stranger. It is never
12
- * an identity — no code path turns it into an actor, and `locateSession()`
13
- * refuses a row with no user — so the worst it can say about the person holding
14
- * it is that they were here.
15
- */
16
6
  export const GUEST_COOKIE = '__Host-fs_guest'
17
7
  export const DEV_GUEST_COOKIE = 'fs_guest'
18
8
 
19
- /** Long enough that a reader is one guest across a visit, short enough to expire. */
20
9
  export const GUEST_COOKIE_DAYS = 1
21
10
 
22
11
  export const ADMIN_COOKIE = 'fs_admin'
@@ -31,7 +20,6 @@ export const DEV_PASSKEY_COOKIE = 'fs_passkey'
31
20
  export const SECOND_FACTOR_COOKIE = '__Host-fs_2fa'
32
21
  export const DEV_SECOND_FACTOR_COOKIE = 'fs_2fa'
33
22
 
34
- /** Long enough to read a consent screen, short enough that a stale one is gone. */
35
23
  export const HANDSHAKE_MINUTES = 10
36
24
 
37
25
  export function sessionCookieName(secure: boolean): string {
@@ -108,26 +96,14 @@ export function secondFactorCookieName(secure: boolean): string {
108
96
  return secure ? SECOND_FACTOR_COOKIE : DEV_SECOND_FACTOR_COOKIE
109
97
  }
110
98
 
111
- /**
112
- * A sign-in that has proved its password and nothing else. Strict, because
113
- * every step that finishes it starts on this board — and short, because it
114
- * stands in for a password already given.
115
- */
116
99
  export function secondFactorCookie(expires: Date, secure: boolean): CookieAttrs {
117
100
  return { httpOnly: true, secure, sameSite: 'strict', path: '/', expires }
118
101
  }
119
102
 
120
- /**
121
- * Lax, not Strict: the identity provider sends the member back with a top-level
122
- * GET, and a Strict cookie is not attached to a navigation that started on
123
- * another site — the handshake would arrive with nothing to check the state
124
- * against and every sign-in would fail.
125
- */
126
103
  export function ssoCookie(secure: boolean): CookieAttrs {
127
104
  return { ...base(secure), maxAge: HANDSHAKE_MINUTES * 60 }
128
105
  }
129
106
 
130
- /** Strict: nothing in the passkey exchange ever leaves this board. */
131
107
  export function passkeyCookie(secure: boolean): CookieAttrs {
132
108
  return {
133
109
  httpOnly: true,
@@ -4,17 +4,6 @@ import { rm } from 'node:fs/promises'
4
4
 
5
5
  import { env, logger } from '@meith/core'
6
6
 
7
- /**
8
- * Everything a visitor uploaded, thrown away with the board that referenced it.
9
- *
10
- * Only local storage is cleared. A demo pointed at an object store is not a
11
- * configuration this project asks anyone to run, and quietly issuing bulk
12
- * deletes against a bucket somebody else's board might share is a worse failure
13
- * than leaving files behind.
14
- *
15
- * Kept apart from the rest of the demo helpers because the container calls this
16
- * one, and the others call the container.
17
- */
18
7
  export async function clearUploadedFiles(): Promise<void> {
19
8
  if (env.FILESTORE_DRIVER !== 'local') {
20
9
  logger({ module: 'demo' }).warn(
@@ -12,13 +12,6 @@ import {
12
12
 
13
13
  import { getContainer } from './container'
14
14
 
15
- /**
16
- * Refuses a change to a published demo login's password or email.
17
- *
18
- * Checked by user id, resolved to a username here, because the demo package
19
- * cannot know the ids — they are handed out by Postgres at seed time and differ
20
- * every reset. The username is the stable thing.
21
- */
22
15
  export async function assertDemoAccountChangeable(
23
16
  userId: number,
24
17
  what: FrozenField,
@@ -31,13 +24,6 @@ export async function assertDemoAccountChangeable(
31
24
  assertDemoAccountIsChangeable(account.username, what)
32
25
  }
33
26
 
34
- /**
35
- * The administrator's own edit screen can rename a member, and renaming
36
- * `admin` is the same lockout as changing its password by a longer route — the
37
- * banner would go on naming a login that no longer exists. Only refused when
38
- * the value actually differs, so an admin can still open the demo login's
39
- * account screen and change its groups.
40
- */
41
27
  export async function assertDemoIdentityUnchanged(
42
28
  userId: number,
43
29
  submitted: { readonly username: string; readonly email: string },
@@ -55,13 +41,6 @@ export async function assertDemoIdentityUnchanged(
55
41
  }
56
42
  }
57
43
 
58
- /**
59
- * The banner's content, or `null` on any board that is not a demo.
60
- *
61
- * Failure here is deliberately quiet. The banner is an aid, and a board that
62
- * refuses to render because it could not work out when it next resets is worse
63
- * than a board that renders without the countdown.
64
- */
65
44
  export async function demoBannerModel(): Promise<DemoBanner | null> {
66
45
  if (!env.DEMO_MODE) return null
67
46
 
@@ -26,12 +26,6 @@ const toFormState = formStateReporter(
26
26
  'unexpected error while changing a sign-in',
27
27
  )
28
28
 
29
- /**
30
- * Only a credential the board would actually accept today counts as a way back
31
- * in. A passkey on a board that has since switched passkeys off, or a link to a
32
- * provider an operator has turned off, opens nothing — counting it would let a
33
- * member unlink their way out of their own account in one click.
34
- */
35
29
  async function usableWaysIn(userId: number): Promise<{
36
30
  readonly usablePasskeys: number
37
31
  readonly usableProviders: readonly string[]
@@ -50,13 +50,6 @@ export function federationOptions(settings: SettingsSnapshot): FederationOptions
50
50
  }
51
51
  }
52
52
 
53
- /**
54
- * Every read here goes past the settings cache, because every one of them
55
- * decides whether a sign-in may start rather than what a page displays. The
56
- * cached snapshot is held per module instance, so a route handler can be a
57
- * minute behind the render that invalidated it — long enough for a provider an
58
- * operator has just switched on to answer that it is switched off.
59
- */
60
53
  export async function federationProvider(id: string): Promise<IdentityProvider | null> {
61
54
  if (!isProviderKind(id)) return null
62
55
  if (getContainer().dataSource !== 'postgres') return null
@@ -15,11 +15,6 @@ export type FeedFormat = 'rss' | 'atom'
15
15
 
16
16
  export type FeedScope = 'board' | 'forum' | 'thread'
17
17
 
18
- /**
19
- * A plugin may reorder, drop, or add. An entry it adds keeps the board's own
20
- * fields where one matches by href, and is otherwise taken as given — a feed
21
- * is rendered as a guest, so everything in it is already public.
22
- */
23
18
  async function filterEntries(
24
19
  entries: readonly FeedEntry[],
25
20
  feed: FeedScope,
@@ -31,9 +31,7 @@ export const getLocale = cache(async (): Promise<ResolvedLocale> => {
31
31
  let boardDefault: string | null = null
32
32
  try {
33
33
  boardDefault = (await getSettings()).get('display.default_locale')
34
- } catch {
35
- /* ignore */
36
- }
34
+ } catch {}
37
35
 
38
36
  return resolveLocale({
39
37
  stored: preferences?.locale ?? null,
@@ -1,37 +1,7 @@
1
1
  export const PATH_HEADER = 'x-forum-path'
2
2
 
3
- /**
4
- * The query string that came with `PATH_HEADER`, leading `?` included, or
5
- * absent when there was none.
6
- *
7
- * Separate from the path because the path has readers that must not see a
8
- * query: a presence row records where somebody is, and `/admin/settings` is one
9
- * place however many groups it can be filtered by. The panel rail is the reader
10
- * that needs both — its settings sections differ only by `?group=`, so a rail
11
- * given the path alone cannot say which one is open.
12
- */
13
3
  export const QUERY_HEADER = 'x-forum-query'
14
4
 
15
- /**
16
- * Set by the middleware when it has just minted the guest cookie, meaning the
17
- * client has not sent one back yet.
18
- *
19
- * It exists because the obvious test does not work: a cookie set on a
20
- * `NextResponse.next()` response is reflected into the request the render sees,
21
- * so neither `cookies()` nor the raw `Cookie` header can tell "the client kept
22
- * this" from "we made it up a millisecond ago". Without the distinction every
23
- * request from something that discards cookies wrote a presence row, which
24
- * measured as twelve rows from twelve crawler requests.
25
- *
26
- * The middleware sets it or deletes it on every request, so a client cannot
27
- * supply its own.
28
- */
29
5
  export const FRESH_GUEST_HEADER = 'x-forum-fresh-guest'
30
6
 
31
- /**
32
- * The per-request Content-Security-Policy nonce minted by `proxy.ts`, for the
33
- * handful of inline `<script>` tags the app emits itself. Next reads the nonce
34
- * out of the policy header on its own; this one is for our tags. See
35
- * `docs/operating.md`.
36
- */
37
7
  export const NONCE_HEADER = 'x-nonce'
@@ -6,12 +6,6 @@ import { drivers } from '@meith/drivers'
6
6
 
7
7
  import { emitEvent, filterView } from './plugin-view'
8
8
 
9
- /**
10
- * Every message the board sends from a request, past the plugins first. A
11
- * plugin may rewrite the recipient, the subject or either body, or return null
12
- * to drop the message — which is silent, because the member asked the board to
13
- * do something and the board's answer is the page, not the mail.
14
- */
15
9
  export async function sendBoardMail(template: string, mail: OutgoingMail): Promise<boolean> {
16
10
  const proposed = await filterView(
17
11
  'mail.send.before',
@@ -9,12 +9,6 @@ import type { FormState } from './auth-form-state'
9
9
  import { tr } from './i18n'
10
10
  import { refreshMarketplaceNow } from './marketplace-admin'
11
11
 
12
- /**
13
- * The Browse tab's "Refresh" button. Runs the exact same fetch, validate,
14
- * cache, notify pass as the daily task (`refreshMarketplaceNow`, shared with
15
- * `packages/runtime`'s task worker) — this is the "on demand" half of that
16
- * one behaviour, not a second implementation of it.
17
- */
18
12
  export async function refreshMarketplaceAction(
19
13
  _prev: FormState,
20
14
  _form: FormData,