@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
@@ -27,20 +27,10 @@ const BUILD_INFO = {
27
27
  themeApiMajor: THEME_API_MAJOR,
28
28
  }
29
29
 
30
- /**
31
- * Whether this process is the stock image rather than a graduated custom
32
- * board — both run identical @meith/web code, so an env var set only by
33
- * docker/Dockerfile is the only signal available: `BOARD_PLUGINS_MANIFEST`,
34
- * baked in for `community board:eject` (see apps/cli/src/board-eject.ts). A
35
- * graduated board's own scaffolded Dockerfile never sets it. Read fresh
36
- * rather than cached at module load, so it is not fixed by whichever test
37
- * happens to import this module first.
38
- */
39
30
  function onStockImage(): boolean {
40
31
  return readPluginEnv('BOARD_PLUGINS_MANIFEST') !== undefined
41
32
  }
42
33
 
43
- /** Caps against untrusted feed content — a self-hosted mirror can serve anything. */
44
34
  const MAX_NAME_LENGTH = 120
45
35
  const MAX_DESCRIPTION_LENGTH = 500
46
36
  const MAX_LICENCE_LENGTH = 100
@@ -50,7 +40,6 @@ function truncate(value: string, max: number): string {
50
40
  return value.length > max ? `${value.slice(0, max - 1)}…` : value
51
41
  }
52
42
 
53
- /** https only, and a real URL — never rendered as a link otherwise. */
54
43
  function safeHttpsUrl(value: string): string | null {
55
44
  try {
56
45
  const url = new URL(value)
@@ -85,27 +74,17 @@ export interface MarketplaceListingRow {
85
74
  readonly package: string
86
75
  readonly licence: string
87
76
  readonly repositoryUrl: string | null
88
- /** Same-origin URLs — the browser never fetches the feed's own host directly. */
89
77
  readonly screenshotHrefs: readonly string[]
90
78
  readonly status: ListingStatus
91
79
  readonly incompatibleReason: string | null
92
80
  readonly installedVersion: string | null
93
- /** Set only for 'not-installed': the exact steps this board would need. Never an affordance that acts. */
94
81
  readonly installSteps: readonly string[] | null
95
- /**
96
- * True only for 'not-installed' on the stock image, where installSteps'
97
- * own `community plugin:add`/`community.config.ts` line cannot actually
98
- * run — the image is fixed at build time. Signposts
99
- * docs/marketplace.md's "Moving to a custom board" walkthrough; never a
100
- * claim this board can graduate itself.
101
- */
102
82
  readonly onStockImage: boolean
103
83
  }
104
84
 
105
85
  export interface MarketplaceCatalogView {
106
86
  readonly listings: readonly MarketplaceListingRow[]
107
87
  readonly fetchedAt: Date | null
108
- /** True once a fetch has ever succeeded — an empty `listings` before that is "not fetched yet", not "empty catalog". */
109
88
  readonly hasEverFetched: boolean
110
89
  readonly unreachable: boolean
111
90
  readonly errorMessage: string | null
@@ -171,12 +150,6 @@ function toRow(
171
150
  }
172
151
  }
173
152
 
174
- /**
175
- * The Browse tab's whole read model for one kind — reads the cached feed
176
- * only (never the network) and computes status against what this build
177
- * actually contains. Renders honestly when the cache is empty: `listings`
178
- * is `[]` either way, and `hasEverFetched`/`unreachable` say why.
179
- */
180
153
  export async function marketplaceCatalog(kind: ListingKind): Promise<MarketplaceCatalogView> {
181
154
  const cache = await readCache()
182
155
  const installed =
@@ -195,7 +168,6 @@ export async function marketplaceCatalog(kind: ListingKind): Promise<Marketplace
195
168
  }
196
169
  }
197
170
 
198
- /** One listing's screenshot, resolved against the feed's own host — never a client-supplied URL. */
199
171
  export async function marketplaceScreenshotUrl(key: string, index: number): Promise<string | null> {
200
172
  const cache = await readCache()
201
173
  if (cache.feed === null || cache.sourceUrl === null) return null
@@ -211,11 +183,6 @@ export async function marketplaceScreenshotUrl(key: string, index: number): Prom
211
183
  }
212
184
  }
213
185
 
214
- /**
215
- * Runs the same fetch, validate, cache, notify pass the daily task does — the
216
- * admin panel's "Refresh" button and the task call the exact same function,
217
- * per docs/marketplace.md.
218
- */
219
186
  export async function refreshMarketplaceNow(): Promise<{
220
187
  readonly ok: boolean
221
188
  readonly listingCount: number
@@ -44,11 +44,6 @@ export function pluginNavigationNames(): NavigationNames {
44
44
  )
45
45
  }
46
46
 
47
- /**
48
- * A row belongs to a plugin that is switched off, or has failed its way off, or
49
- * is no longer in the build. It is left in place — an operator's ordering is
50
- * worth keeping — but it is not a link anybody should be offered.
51
- */
52
47
  async function pluginRowIsLive(key: string | null): Promise<boolean> {
53
48
  const pluginKey = pluginKeyOfNavigation(key)
54
49
  if (pluginKey === null) return true
@@ -7,6 +7,7 @@ import { msg } from '@meith/i18n'
7
7
  import type { NotificationService } from '@meith/notifications'
8
8
 
9
9
  import type { FormState } from './auth-form-state'
10
+ import { getContainer } from './container'
10
11
  import { getActor } from './context'
11
12
  import { formStateReporter } from './form-state-reporter'
12
13
  import { positiveInt } from './form-values'
@@ -62,6 +63,30 @@ export async function markAllNotificationsReadAction(
62
63
  redirect('/notifications?read=all')
63
64
  }
64
65
 
66
+ export async function saveMassMailOptInAction(
67
+ _prev: FormState,
68
+ form: FormData,
69
+ ): Promise<FormState> {
70
+ try {
71
+ const actor = await getActor()
72
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
73
+
74
+ const { memberSettings } = getContainer()
75
+ if (memberSettings === null) {
76
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-9'))
77
+ }
78
+
79
+ await memberSettings.saveMassMailOptIn({
80
+ userId: actor.userId,
81
+ optIn: form.get('announcements') !== null,
82
+ })
83
+ } catch (err) {
84
+ return toFormState(err)
85
+ }
86
+
87
+ redirect('/notifications/preferences?saved=announcements')
88
+ }
89
+
65
90
  export async function saveNotificationPreferencesAction(
66
91
  _prev: FormState,
67
92
  form: FormData,
@@ -28,11 +28,6 @@ async function dismissed(panel: OnboardingPanel): Promise<boolean> {
28
28
  return (await cookies()).get(onboardingCookieName(panel))?.value === '1'
29
29
  }
30
30
 
31
- /**
32
- * At most one of the welcome banner or the profile-completion nudge, never
33
- * both — stacking unrelated notices in the same board-wide slot is the
34
- * "heavy multi-step tour" the feature is meant to avoid.
35
- */
36
31
  export async function onboardingBanner(actor: Actor): Promise<OnboardingNoticeModel | null> {
37
32
  if (actor.userId === null) return null
38
33
 
@@ -74,11 +74,6 @@ export async function setPluginEnabledAction(_prev: FormState, form: FormData):
74
74
  await syncPluginEnablement()
75
75
  await syncPluginNavigation()
76
76
 
77
- /*
78
- * After the switch is written, so the callback sees the state it is being
79
- * told about, and never for the host's own failure switch: a plugin that
80
- * has just failed five times is not one to hand more work to.
81
- */
82
77
  await announceSwitch(definition, enabled)
83
78
 
84
79
  await recordAdminAction({
@@ -94,12 +89,6 @@ export async function setPluginEnabledAction(_prev: FormState, form: FormData):
94
89
  }
95
90
  }
96
91
 
97
- /**
98
- * The switch has already been thrown by the time this runs, and it stays
99
- * thrown: a callback that throws is the plugin's fault, so it is counted and
100
- * reported like any other plugin failure rather than being reported as the
101
- * operator's action having failed.
102
- */
103
92
  async function announceSwitch(definition: PluginDefinition, enabled: boolean): Promise<void> {
104
93
  if (enabled) {
105
94
  await emitEvent('plugin.enabled', { pluginKey: definition.key }, {})
@@ -33,12 +33,10 @@ export interface PluginSettingRow {
33
33
  readonly description: string | null
34
34
  readonly advanced: boolean
35
35
  readonly kind: PluginSettingKind
36
- /** '' for a secret, whatever is stored or not: the value never leaves the server. */
37
36
  readonly default: PluginSettingValue
38
37
  readonly value: PluginSettingValue
39
38
  readonly overridden: boolean
40
39
  readonly source: PluginSettingSource
41
- /** For secrets: whether a non-empty value is in force, without saying what it is. */
42
40
  readonly set: boolean
43
41
  readonly options: readonly { readonly value: string; readonly label: string }[]
44
42
  readonly envName: string | null
@@ -81,7 +79,6 @@ export interface PluginRow {
81
79
  readonly running: boolean
82
80
 
83
81
  readonly health: PluginHealth | null
84
- /** The record every instance reconciles against, not this process's tally. */
85
82
  readonly durableHealth: PluginDurableHealthRow | null
86
83
  readonly hooks: readonly string[]
87
84
  readonly regions: readonly string[]
@@ -14,6 +14,7 @@ import {
14
14
  PluginHost,
15
15
  } from '@meith/plugin-kit'
16
16
 
17
+ import { runtimeContextFor } from './plugin-runtime'
17
18
  import { getSettingOverrides } from './settings'
18
19
 
19
20
  const HEALTH_TTL_SECONDS = 30
@@ -47,9 +48,23 @@ export function activeDefinitions(): readonly PluginDefinition[] {
47
48
  .map((entry) => entry.plugin as PluginDefinition)
48
49
  }
49
50
 
51
+ async function hookRuntimeContext(pluginKey: string) {
52
+ const definition = activeDefinitions().find((candidate) => candidate.key === pluginKey)
53
+ if (definition === undefined) {
54
+ throw new Error(`plugin "${pluginKey}" is not installed on this board.`)
55
+ }
56
+ return runtimeContextFor(
57
+ pluginKey,
58
+ definition,
59
+ await getSettingOverrides(),
60
+ logger({ component: 'plugin-hook', plugin: pluginKey }),
61
+ )
62
+ }
63
+
50
64
  export const pluginHost = new PluginHost({
51
65
  plugins: activeDefinitions(),
52
66
  failureThreshold: FAILURE_THRESHOLD,
67
+ runtime: hookRuntimeContext,
53
68
  logger: {
54
69
  warn: (message, detail) => logger().warn(detail, message),
55
70
  error: (message, detail) => logger().error(detail, message),
@@ -61,12 +76,6 @@ export const pluginHost = new PluginHost({
61
76
  },
62
77
  })
63
78
 
64
- /**
65
- * A plugin failing somewhere the host does not itself dispatch — a lifecycle
66
- * callback — counted and reported exactly as a failing hook is. There is one
67
- * failure record per plugin, and an operator reading it should not have to know
68
- * which door the plugin was behind when it broke.
69
- */
70
79
  export async function recordPluginFault(
71
80
  pluginKey: string,
72
81
  surface: string,
@@ -3,78 +3,13 @@ import 'server-only'
3
3
  import forumConfig from '@board/config'
4
4
  import type { ReactNode } from 'react'
5
5
 
6
- import { env, logger, readPluginEnv } from '@meith/core'
7
- import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
8
- import {
9
- type PluginData,
10
- type PluginDefinition,
11
- type PluginGrants,
12
- type PluginNotify,
13
- type PluginRuntimeContext,
14
- type PluginUsers,
15
- pluginNotify,
16
- resolvePluginSettings,
17
- unavailablePluginData,
18
- unavailablePluginGrants,
19
- unavailablePluginNotify,
20
- unavailablePluginUsers,
21
- } from '@meith/plugin-kit'
6
+ import { logger } from '@meith/core'
7
+ import type { PluginDefinition } from '@meith/plugin-kit'
22
8
 
23
9
  import { getTranslator } from './i18n'
24
- import { notificationService } from './notifications'
10
+ import { runtimeContextFor } from './plugin-runtime'
25
11
  import { getSettingOverrides } from './settings'
26
12
 
27
- const REQUEST_STATEMENT_TIMEOUT_MS = 3_000
28
-
29
- export function grantsFor(pluginKey: string): PluginGrants {
30
- return env.DATA_SOURCE === 'postgres'
31
- ? pluginGrants(getDb(), pluginKey)
32
- : unavailablePluginGrants('this board is running on in-memory sample data')
33
- }
34
-
35
- export function dataFor(pluginKey: string): PluginData {
36
- return env.DATA_SOURCE === 'postgres'
37
- ? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
38
- : unavailablePluginData('this board is running on in-memory sample data')
39
- }
40
-
41
- export function usersFor(): PluginUsers {
42
- return env.DATA_SOURCE === 'postgres'
43
- ? pluginUsers(getDb())
44
- : unavailablePluginUsers('this board is running on in-memory sample data')
45
- }
46
-
47
- export function notifyFor(pluginKey: string): PluginNotify {
48
- const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
49
- const definition = entry?.plugin as PluginDefinition | undefined
50
- const service = notificationService()
51
-
52
- if (definition === undefined || service === null) {
53
- return unavailablePluginNotify('this board is running on in-memory sample data')
54
- }
55
- return pluginNotify(pluginKey, definition.notifications ?? [], service)
56
- }
57
-
58
- export function runtimeContextFor(
59
- pluginKey: string,
60
- definition: PluginDefinition,
61
- overrides: ReadonlyMap<string, string>,
62
- log: ReturnType<typeof logger>,
63
- ): PluginRuntimeContext {
64
- return {
65
- settings: resolvePluginSettings(definition, overrides, readPluginEnv),
66
- logger: {
67
- info: (message, detail) => log.info(detail ?? {}, message),
68
- warn: (message, detail) => log.warn(detail ?? {}, message),
69
- error: (message, detail) => log.error(detail ?? {}, message),
70
- },
71
- grants: grantsFor(pluginKey),
72
- data: dataFor(pluginKey),
73
- users: usersFor(),
74
- notify: notifyFor(pluginKey),
75
- }
76
- }
77
-
78
13
  export interface RenderedPluginPage {
79
14
  readonly title: string
80
15
  readonly node: ReactNode | null
@@ -17,7 +17,7 @@ import { recordAdminAction, requireAdmin } from './admin'
17
17
  import { boardUrl } from './board-url'
18
18
  import { getActor } from './context'
19
19
  import { activeDefinitions, pluginHost, syncPluginEnablement } from './plugin-host'
20
- import { runtimeContextFor } from './plugin-pages'
20
+ import { runtimeContextFor } from './plugin-runtime'
21
21
  import { viewerRef } from './plugin-view'
22
22
  import { isSafeLocalPath } from './safe-path'
23
23
  import { isSameOrigin } from './same-origin'
@@ -0,0 +1,78 @@
1
+ import 'server-only'
2
+
3
+ import forumConfig from '@board/config'
4
+
5
+ import { env, type logger, readPluginEnv } from '@meith/core'
6
+ import { getDb, pluginData, pluginGrants, pluginUsers } from '@meith/db'
7
+ import {
8
+ type PluginData,
9
+ type PluginDefinition,
10
+ type PluginGrants,
11
+ type PluginNotify,
12
+ type PluginRuntimeContext,
13
+ type PluginUsers,
14
+ pluginNotify,
15
+ resolvePluginSettings,
16
+ unavailablePluginData,
17
+ unavailablePluginGrants,
18
+ unavailablePluginNotify,
19
+ unavailablePluginUsers,
20
+ } from '@meith/plugin-kit'
21
+
22
+ const REQUEST_STATEMENT_TIMEOUT_MS = 3_000
23
+
24
+ export function grantsFor(pluginKey: string): PluginGrants {
25
+ return env.DATA_SOURCE === 'postgres'
26
+ ? pluginGrants(getDb(), pluginKey)
27
+ : unavailablePluginGrants('this board is running on in-memory sample data')
28
+ }
29
+
30
+ export function dataFor(pluginKey: string): PluginData {
31
+ return env.DATA_SOURCE === 'postgres'
32
+ ? pluginData(getDb(), pluginKey, { statementTimeoutMs: REQUEST_STATEMENT_TIMEOUT_MS })
33
+ : unavailablePluginData('this board is running on in-memory sample data')
34
+ }
35
+
36
+ export function usersFor(): PluginUsers {
37
+ return env.DATA_SOURCE === 'postgres'
38
+ ? pluginUsers(getDb())
39
+ : unavailablePluginUsers('this board is running on in-memory sample data')
40
+ }
41
+
42
+ export function notifyFor(pluginKey: string): PluginNotify {
43
+ return {
44
+ async send(input) {
45
+ const { notificationService } = await import('./notifications')
46
+ const entry = (forumConfig.plugins ?? []).find((candidate) => candidate.key === pluginKey)
47
+ const definition = entry?.plugin as PluginDefinition | undefined
48
+ const service = notificationService()
49
+
50
+ const notify =
51
+ definition === undefined || service === null
52
+ ? unavailablePluginNotify('this board is running on in-memory sample data')
53
+ : pluginNotify(pluginKey, definition.notifications ?? [], service)
54
+
55
+ return notify.send(input)
56
+ },
57
+ }
58
+ }
59
+
60
+ export function runtimeContextFor(
61
+ pluginKey: string,
62
+ definition: PluginDefinition,
63
+ overrides: ReadonlyMap<string, string>,
64
+ log: ReturnType<typeof logger>,
65
+ ): PluginRuntimeContext {
66
+ return {
67
+ settings: resolvePluginSettings(definition, overrides, readPluginEnv),
68
+ logger: {
69
+ info: (message, detail) => log.info(detail ?? {}, message),
70
+ warn: (message, detail) => log.warn(detail ?? {}, message),
71
+ error: (message, detail) => log.error(detail ?? {}, message),
72
+ },
73
+ grants: grantsFor(pluginKey),
74
+ data: dataFor(pluginKey),
75
+ users: usersFor(),
76
+ notify: notifyFor(pluginKey),
77
+ }
78
+ }
@@ -40,9 +40,7 @@ export async function touchCurrentLocation(): Promise<void> {
40
40
  const location = parseLocation((await headers()).get(PATH_HEADER))
41
41
  if (location === null) return
42
42
  await touchLocation(location)
43
- } catch {
44
- /* ignore */
45
- }
43
+ } catch {}
46
44
  }
47
45
 
48
46
  export function presenceRepository(): PostgresPresenceRepository | null {
@@ -78,22 +76,9 @@ export async function touchLocation(location: BoardLocation): Promise<void> {
78
76
  new Date(),
79
77
  LOCATION_WINDOW_SECONDS,
80
78
  )
81
- } catch {
82
- /* ignore */
83
- }
79
+ } catch {}
84
80
  }
85
81
 
86
- /**
87
- * The guest token, but only once the client has proved it keeps cookies.
88
- *
89
- * The freshness header is the proof, and it has to come from the middleware
90
- * because nothing downstream can supply it: Next reflects a cookie set on a
91
- * `NextResponse.next()` into the request the render sees, so on a first-ever
92
- * visit both `cookies()` and the raw `Cookie` header already carry a token the
93
- * client has never seen. Counting on that wrote one presence row per request
94
- * from anything that discards cookies — measured at twelve rows from twelve
95
- * crawler requests, and a sessions table that grows without bound.
96
- */
97
82
  async function returnedGuestToken(secure: boolean): Promise<string | undefined> {
98
83
  if ((await headers()).get(FRESH_GUEST_HEADER) === '1') return undefined
99
84
 
@@ -101,13 +86,6 @@ async function returnedGuestToken(secure: boolean): Promise<string | undefined>
101
86
  return value === '' ? undefined : value
102
87
  }
103
88
 
104
- /**
105
- * The same reader, one row.
106
- *
107
- * Only reached when there is no usable member session, which is what keeps a
108
- * logged-in member from being counted twice — and signing in drops the guest
109
- * row outright, so the two never overlap.
110
- */
111
89
  async function touchGuestLocation(
112
90
  guestToken: string | undefined,
113
91
  location: BoardLocation,
@@ -131,13 +109,6 @@ async function touchGuestLocation(
131
109
  })
132
110
  }
133
111
 
134
- /**
135
- * Retires the guest identity of somebody who has just become a member.
136
- *
137
- * Called wherever a session cookie is set, rather than only at the login form,
138
- * because that is every route into being signed in — the form, the remember-me
139
- * resume, and a password change that reissues the session.
140
- */
141
112
  export async function retireGuestPresence(): Promise<void> {
142
113
  try {
143
114
  const secure = env.NODE_ENV !== 'development'
@@ -149,9 +120,7 @@ export async function retireGuestPresence(): Promise<void> {
149
120
  }
150
121
 
151
122
  jar.set(guestCookieName(secure), '', clearedCookie(secure))
152
- } catch {
153
- /* ignore */
154
- }
123
+ } catch {}
155
124
  }
156
125
 
157
126
  export async function onlineScopeFor(actor: Actor): Promise<OnlineScope> {
@@ -39,7 +39,5 @@ export async function touchActivity(userId: number | null): Promise<void> {
39
39
  try {
40
40
  const { accountStore } = getContainer()
41
41
  await accountStore.accounts.touchLastActive(userId, new Date(), ACTIVITY_WINDOW_SECONDS)
42
- } catch {
43
- /* ignore */
44
- }
42
+ } catch {}
45
43
  }
@@ -131,6 +131,7 @@ export async function submitReply(
131
131
  heldAsNewMember: await holdsNewMember({
132
132
  actor,
133
133
  postCount: profile.postCount,
134
+ target: scope,
134
135
  settings,
135
136
  }),
136
137
  requiresApproval: scope.forum.requiresPostApproval === true,
@@ -53,7 +53,6 @@ export async function countingAddress(): Promise<string | null> {
53
53
  return demoDiscardsAddresses() ? demoAddressToken(address) : address
54
54
  }
55
55
 
56
- /** Where a request came from and what made it, as much as is kept about either. */
57
56
  export async function requestFingerprint(): Promise<RequestFingerprint> {
58
57
  const incoming = await headers()
59
58
  const agent = incoming.get('user-agent') ?? ''
@@ -185,10 +185,6 @@ export async function openSearch(input: {
185
185
  }
186
186
  }
187
187
 
188
- /**
189
- * A plugin may reorder or drop; a row it invents has no hit behind it and is
190
- * dropped, because the hits arrived permission-filtered in SQL.
191
- */
192
188
  async function filterHits(
193
189
  results: SearchResults,
194
190
  terms: string,
@@ -25,7 +25,6 @@ async function requireViewer(): Promise<number> {
25
25
  return actor.userId
26
26
  }
27
27
 
28
- /** The row the member is reading the page on, so it can be marked and kept. */
29
28
  export async function currentSessionId(): Promise<number | null> {
30
29
  const token = await readSessionToken()
31
30
  if (token === undefined || token === '') return null
@@ -28,17 +28,6 @@ export async function getSettings(): Promise<SettingsSnapshot> {
28
28
  return SettingsSnapshot.fromOverrides(new Map(await getSettingOverrides()))
29
29
  }
30
30
 
31
- /**
32
- * The same settings, read past the cache.
33
- *
34
- * The cached snapshot is held per module instance, and a route handler is not
35
- * always the same instance as the render that invalidated it — so a route can
36
- * act on a value up to a minute out of date. That is harmless for anything the
37
- * page merely displays, and wrong for a decision an operator has just changed:
38
- * turning a sign-in provider on and having the button answer "that is not
39
- * switched on" reads as a broken board, not as a stale cache. Sign-in happens
40
- * once per member per session, so the query is cheaper than the confusion.
41
- */
42
31
  export async function getSettingsUncached(): Promise<SettingsSnapshot> {
43
32
  if (env.DATA_SOURCE !== 'postgres') return SettingsSnapshot.fromOverrides(new Map())
44
33
 
@@ -9,6 +9,7 @@ import {
9
9
  parseSubscriptionTarget,
10
10
  readUnsubscribeToken,
11
11
  SubscriptionService,
12
+ type UnsubscribeScope,
12
13
  } from '@meith/subscriptions'
13
14
 
14
15
  import type { FormState } from './auth-form-state'
@@ -127,12 +128,14 @@ export async function unsubscribeByTokenAction(
127
128
  if (claim === null) return { error: await tr('notice.app.unsubscribe-link-valid') }
128
129
 
129
130
  try {
130
- const { subscriptions, notifications } = getContainer()
131
- if (subscriptions === null || notifications === null) {
131
+ const { subscriptions, notifications, memberSettings } = getContainer()
132
+ if (subscriptions === null || notifications === null || memberSettings === null) {
132
133
  throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-17'))
133
134
  }
134
135
 
135
- if (claim.scope === 'email') {
136
+ if (claim.scope === 'mass-mail') {
137
+ await memberSettings.saveMassMailOptIn({ userId: claim.userId, optIn: false })
138
+ } else if (claim.scope === 'email') {
136
139
  await notifications.savePreferences(
137
140
  claim.userId,
138
141
  'email',
@@ -152,7 +155,12 @@ export async function unsubscribeByTokenAction(
152
155
  return toFormState(err)
153
156
  }
154
157
 
155
- redirect(`/unsubscribe?done=${claim.scope === 'email' ? 'email' : 'one'}`)
158
+ redirect(`/unsubscribe?done=${doneFor(claim.scope)}`)
159
+ }
160
+
161
+ function doneFor(scope: UnsubscribeScope): string {
162
+ if (scope === 'mass-mail') return 'announcements'
163
+ return scope === 'email' ? 'email' : 'one'
156
164
  }
157
165
 
158
166
  function safeReturn(value: string, fallback: string): string {
@@ -50,10 +50,6 @@ export async function isIndexable(): Promise<boolean> {
50
50
  }
51
51
  }
52
52
 
53
- /**
54
- * One chunk of the sitemap, after the plugins have had it. Everything here is
55
- * public by definition — the sitemap is what the board offers a crawler.
56
- */
57
53
  export async function filterSitemapEntries(
58
54
  urls: readonly SitemapUrl[],
59
55
  chunk: number,
@@ -106,9 +106,7 @@ export async function buildThemeAdminView(key: string): Promise<ThemeAdminView |
106
106
  let overrides: TokenOverrides = { light: {}, dark: {} }
107
107
  try {
108
108
  overrides = validateTokenOverrides(installed.tokens, record?.tokenOverrides)
109
- } catch {
110
- /* ignore */
111
- }
109
+ } catch {}
112
110
 
113
111
  const listing = (await themeListing()).find((entry) => entry.key === key)
114
112
 
@@ -179,9 +177,7 @@ export async function boardSampleSurfaces(): Promise<{
179
177
  .catch(() => null)
180
178
  try {
181
179
  overrides = validateTokenOverrides(theme.tokens, record?.tokenOverrides)
182
- } catch {
183
- /* ignore */
184
- }
180
+ } catch {}
185
181
  }
186
182
 
187
183
  const pick = (
@@ -126,7 +126,12 @@ export async function submitThread(
126
126
  message: draft.body,
127
127
  prefixId: draft.prefixId,
128
128
  subscribe: input.subscribe ?? false,
129
- heldAsNewMember: await holdsNewMember({ actor, postCount: profile.postCount, settings }),
129
+ heldAsNewMember: await holdsNewMember({
130
+ actor,
131
+ postCount: profile.postCount,
132
+ target: scope,
133
+ settings,
134
+ }),
130
135
  requiresApproval: scope.forum.requiresThreadApproval === true,
131
136
  ...(input.poll === undefined
132
137
  ? {}
@@ -146,11 +146,6 @@ export async function disableTwoFactorAction(_prev: FormState, form: FormData):
146
146
  redirect('/usercp/security?factor=off')
147
147
  }
148
148
 
149
- /**
150
- * Weakening an account asks for the thing being weakened. An account with a
151
- * password gives it; one without — signed in by a provider or a passkey —
152
- * gives a current code instead, which proves the same possession.
153
- */
154
149
  async function assertStillThem(input: {
155
150
  readonly userId: number
156
151
  readonly hasPassword: boolean