@meith/settings 0.16.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.
@@ -0,0 +1,1088 @@
1
+ import { z } from 'zod'
2
+
3
+ import { normaliseLocale, SOURCE_LOCALE } from '@meith/i18n'
4
+
5
+ import { DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from './legal'
6
+ import { isUsableFeedUrl, isUsableIssuer, isUsableOrigin } from './origin'
7
+
8
+ export type SettingGroup =
9
+ | 'board'
10
+ | 'registration'
11
+ | 'posting'
12
+ | 'display'
13
+ | 'search'
14
+ | 'mail'
15
+ | 'reputation'
16
+ | 'security'
17
+ | 'federation'
18
+ | 'antispam'
19
+ | 'push'
20
+ | 'legal'
21
+ | 'marketplace'
22
+
23
+ interface SettingDefinitionBase<T> {
24
+ readonly key: string
25
+ readonly group: SettingGroup
26
+ readonly label: string
27
+ readonly description: string
28
+ readonly schema: z.ZodType<T>
29
+ readonly default: T
30
+ readonly invalidates?: readonly string[]
31
+ readonly secret?: boolean
32
+ readonly ui?: {
33
+ readonly multiline?: boolean
34
+ readonly options?: readonly {
35
+ readonly value: string
36
+ readonly label: string
37
+ }[]
38
+ readonly min?: number
39
+ readonly max?: number
40
+ readonly advanced?: boolean
41
+ readonly managed?: boolean
42
+ }
43
+ }
44
+
45
+ export type SettingDefinition<T = unknown> = SettingDefinitionBase<T>
46
+
47
+ function define<T, K extends string>(
48
+ d: SettingDefinitionBase<T> & { readonly key: K },
49
+ ): SettingDefinition<T> & { readonly key: K } {
50
+ return d
51
+ }
52
+
53
+ export const SETTING_DEFINITIONS = [
54
+ define({
55
+ key: 'board.name',
56
+ group: 'board',
57
+ label: 'Board name',
58
+ description: 'Shown in the header, page titles, and outgoing e-mail.',
59
+ schema: z.string().min(1).max(100),
60
+ default: 'Meith',
61
+ invalidates: ['settings', 'layout'],
62
+ }),
63
+ define({
64
+ key: 'board.url',
65
+ group: 'board',
66
+ label: 'Board address',
67
+ description:
68
+ 'The absolute public origin, with no trailing slash — https://forum.example. ' +
69
+ 'Every link the board sends is built from it, because nothing in a queued ' +
70
+ 'job or a mail template knows the request that caused it. Left empty, mail ' +
71
+ 'still arrives and carries no link. Setting APP_URL in the environment ' +
72
+ 'overrides this and makes the box below inert.',
73
+ schema: z
74
+ .string()
75
+ .trim()
76
+ .refine(
77
+ (value) => value === '' || isUsableOrigin(value),
78
+ 'Give an absolute http(s) address with no path — https://forum.example.',
79
+ ),
80
+ default: '',
81
+ invalidates: ['settings', 'layout'],
82
+ }),
83
+ define({
84
+ key: 'board.description',
85
+ group: 'board',
86
+ label: 'Board description',
87
+ description: 'Used as the default meta description for the board index.',
88
+ schema: z.string().max(300),
89
+ default: '',
90
+ invalidates: ['settings', 'layout'],
91
+ ui: { multiline: true },
92
+ }),
93
+ define({
94
+ key: 'board.offline',
95
+ group: 'board',
96
+ label: 'Board offline',
97
+ description:
98
+ 'Closes every board page to everyone without the "can view board offline" ' +
99
+ 'permission: they get the offline message instead, and the board’s feeds ' +
100
+ 'answer 503. Administrators keep access so they can finish maintenance, and ' +
101
+ 'the login screen, the control panel and the health endpoint stay reachable. ' +
102
+ 'An offline board is never indexable.',
103
+ schema: z.boolean(),
104
+ default: false,
105
+ invalidates: ['settings', 'layout'],
106
+ ui: { advanced: true },
107
+ }),
108
+ define({
109
+ key: 'board.offline_message',
110
+ group: 'board',
111
+ label: 'Offline message',
112
+ description:
113
+ 'Shown on the offline page, and returned as the body of the 503 the feeds ' +
114
+ 'answer with. Left empty, a plain maintenance line is shown instead.',
115
+ schema: z.string().max(2000),
116
+ default: 'The board is temporarily unavailable for maintenance.',
117
+ invalidates: ['settings'],
118
+ ui: { multiline: true, advanced: true },
119
+ }),
120
+
121
+ define({
122
+ key: 'registration.enabled',
123
+ group: 'registration',
124
+ label: 'Allow new registrations',
125
+ description:
126
+ 'Off closes the board to new members: the Register link goes, /register ' +
127
+ 'says so instead of offering a form, and the action behind it refuses a ' +
128
+ 'submission sent straight to it. Existing members sign in as before, the ' +
129
+ 'installer still creates the first administrator, and ' +
130
+ '"community user:create" still works — closing the door is not the same ' +
131
+ 'as locking yourself out.',
132
+ schema: z.boolean(),
133
+ default: true,
134
+ invalidates: ['settings'],
135
+ }),
136
+ define({
137
+ key: 'registration.method',
138
+ group: 'registration',
139
+ label: 'Activation method',
140
+ description:
141
+ '"none" logs the user straight in. "email" requires a link. "admin" ' +
142
+ 'queues the account for manual approval. "both" requires e-mail then admin.',
143
+ schema: z.enum(['none', 'email', 'admin', 'both']),
144
+ default: 'none',
145
+ invalidates: ['settings'],
146
+ ui: {
147
+ options: [
148
+ { value: 'none', label: 'Nothing further — the account works at once' },
149
+ { value: 'email', label: 'Confirm the e-mail address' },
150
+ { value: 'admin', label: 'An administrator approves each account' },
151
+ {
152
+ value: 'both',
153
+ label: 'Confirm the address, then an administrator approves',
154
+ },
155
+ ],
156
+ },
157
+ }),
158
+ define({
159
+ key: 'registration.min_password_length',
160
+ group: 'registration',
161
+ label: 'Minimum password length',
162
+ description:
163
+ 'Applied to new passwords only. Raising it does not invalidate existing ' +
164
+ 'ones — those are rehashed on next login.',
165
+ schema: z.number().int().min(8).max(128),
166
+ default: 10,
167
+ ui: { min: 8, max: 128 },
168
+ }),
169
+ define({
170
+ key: 'registration.username_min',
171
+ group: 'registration',
172
+ label: 'Minimum username length',
173
+ description: 'Counted in Unicode code points, not bytes.',
174
+ schema: z.number().int().min(1).max(64),
175
+ default: 3,
176
+ ui: { min: 1, max: 64 },
177
+ }),
178
+ define({
179
+ key: 'registration.username_max',
180
+ group: 'registration',
181
+ label: 'Maximum username length',
182
+ description: 'Must not exceed the 64-character database column.',
183
+ schema: z.number().int().min(1).max(64),
184
+ default: 30,
185
+ ui: { min: 1, max: 64 },
186
+ }),
187
+
188
+ define({
189
+ key: 'posting.flood_seconds',
190
+ group: 'posting',
191
+ label: 'Post flood interval',
192
+ description:
193
+ 'Minimum seconds between posts by one user. 0 disables the check. ' +
194
+ 'Users with "bypass flood check" are exempt.',
195
+ schema: z.number().int().min(0).max(3600),
196
+ default: 15,
197
+ ui: { min: 0, max: 3600 },
198
+ }),
199
+ define({
200
+ key: 'posting.max_length',
201
+ group: 'posting',
202
+ label: 'Maximum post length',
203
+ description: 'Characters of source text, before rendering.',
204
+ schema: z.number().int().min(100).max(200_000),
205
+ default: 30_000,
206
+ ui: { min: 100, max: 200_000 },
207
+ }),
208
+ define({
209
+ key: 'posting.thread_ratings_enabled',
210
+ group: 'posting',
211
+ label: 'Thread ratings enabled',
212
+ description: 'Members with permission can give each thread one 1–5 rating.',
213
+ schema: z.boolean(),
214
+ default: true,
215
+ invalidates: ['settings'],
216
+ }),
217
+ define({
218
+ key: 'posting.edit_grace_seconds',
219
+ group: 'posting',
220
+ label: 'Silent edit window',
221
+ description:
222
+ 'Seconds after posting during which an author fixing their own post ' +
223
+ 'leaves no "edited by" notice on it. 0 always shows the notice. A ' +
224
+ "moderator editing somebody else's post is never silent, however soon " +
225
+ 'it happens, and the revision history records every edit either way — ' +
226
+ 'this hides the line under the post, nothing else.',
227
+ schema: z.number().int().min(0).max(86_400),
228
+ default: 300,
229
+ ui: { min: 0, max: 86_400 },
230
+ }),
231
+
232
+ define({
233
+ key: 'display.threads_per_page',
234
+ group: 'display',
235
+ label: 'Threads per page',
236
+ description: 'Capped to protect the forum-display query budget.',
237
+ schema: z.number().int().min(5).max(100),
238
+ default: 25,
239
+ invalidates: ['settings'],
240
+ ui: { min: 5, max: 100 },
241
+ }),
242
+ define({
243
+ key: 'display.posts_per_page',
244
+ group: 'display',
245
+ label: 'Posts per page',
246
+ description: 'Capped to protect the thread-view query budget.',
247
+ schema: z.number().int().min(5).max(100),
248
+ default: 20,
249
+ invalidates: ['settings'],
250
+ ui: { min: 5, max: 100 },
251
+ }),
252
+
253
+ define({
254
+ key: 'display.default_locale',
255
+ group: 'display',
256
+ label: 'Default language',
257
+ description:
258
+ 'The language a page is written in when the reader has expressed no preference — ' +
259
+ 'a BCP-47 tag such as en, de or pt-BR. A signed-in member overrides it in their ' +
260
+ 'control panel, and a visitor who has not is served whichever language their ' +
261
+ 'browser asks for that this board has a catalog for. Left at a language nothing ' +
262
+ 'translates, every message falls back to English rather than disappearing.',
263
+ schema: z
264
+ .string()
265
+ .trim()
266
+ .refine((value) => normaliseLocale(value) !== null, 'Give a language tag — en, de, pt-BR.'),
267
+ default: SOURCE_LOCALE,
268
+ invalidates: ['settings', 'layout'],
269
+ }),
270
+
271
+ define({
272
+ key: 'board.logo_light',
273
+ group: 'board',
274
+ label: 'Logo (light)',
275
+ description: 'Shown in the header in place of the board name. Uploaded, not typed.',
276
+ schema: z.string().max(300),
277
+ default: '',
278
+ invalidates: ['settings', 'layout'],
279
+ ui: { managed: true },
280
+ }),
281
+ define({
282
+ key: 'board.logo_dark',
283
+ group: 'board',
284
+ label: 'Logo (dark)',
285
+ description: 'Used when the reader is in dark mode. Falls back to the light one.',
286
+ schema: z.string().max(300),
287
+ default: '',
288
+ invalidates: ['settings', 'layout'],
289
+ ui: { managed: true },
290
+ }),
291
+ define({
292
+ key: 'board.logo_alt',
293
+ group: 'board',
294
+ label: 'Logo alt text',
295
+ description:
296
+ 'What a screen reader announces in place of the logo. Leave empty to use ' +
297
+ 'the board name, which is usually what the logo says anyway.',
298
+ schema: z.string().max(200),
299
+ default: '',
300
+ invalidates: ['settings', 'layout'],
301
+ }),
302
+
303
+ define({
304
+ key: 'search.enabled',
305
+ group: 'search',
306
+ label: 'Enable search',
307
+ description:
308
+ 'Off takes the Search link out of the board navigation, replaces /search ' +
309
+ 'and any results page still linked to with a line saying so, and answers ' +
310
+ 'GET /api/v1/search with a 403. The index is kept and goes on being ' +
311
+ 'maintained, so switching it back on needs no reindex.',
312
+ schema: z.boolean(),
313
+ default: true,
314
+ invalidates: ['settings'],
315
+ }),
316
+ define({
317
+ key: 'board.legacy_redirects',
318
+ group: 'board',
319
+ label: 'Redirect old forum URLs',
320
+ description:
321
+ 'Answer legacy addresses — MyBB’s showthread.php forms and rewritten routes, plus ' +
322
+ 'phpBB’s viewtopic.php, viewforum.php and memberlist.php — with a permanent ' +
323
+ 'redirect (308) to the imported content. Needs an import: the redirect is a lookup in the legacy id map.',
324
+ schema: z.boolean(),
325
+ default: false,
326
+ }),
327
+ define({
328
+ key: 'search.flood_seconds',
329
+ group: 'search',
330
+ label: 'Search flood interval',
331
+ description:
332
+ 'Minimum seconds between searches per user. Replaces the per-group ' +
333
+ 'searchfloodtime permission, which could not obey the numeric ' +
334
+ 'combination rule — see docs/mybb-parity.md.',
335
+ schema: z.number().int().min(0).max(3600),
336
+ default: 30,
337
+ ui: { min: 0, max: 3600 },
338
+ }),
339
+ define({
340
+ key: 'search.min_word_length',
341
+ group: 'search',
342
+ label: 'Shortest word a search may rest on',
343
+ description:
344
+ 'A search is refused unless at least one of its words is this long, so ' +
345
+ 'at 3 "a good post" runs and "a b c" does not. The short words are not ' +
346
+ 'dropped — they are still sent to the index, which drops the ones ' +
347
+ 'carrying no meaning on its own. Raise it if short words are what your ' +
348
+ 'expensive searches turn out to be.',
349
+ schema: z.number().int().min(1).max(10),
350
+ default: 2,
351
+ ui: { min: 1, max: 10 },
352
+ }),
353
+
354
+ define({
355
+ key: 'mail.transport',
356
+ group: 'mail',
357
+ label: 'How mail is sent',
358
+ description:
359
+ 'A provider’s JSON API, an SMTP server, or nothing at all. "Not sending" ' +
360
+ 'writes each message to the server log and delivers none of them — which ' +
361
+ 'is the right default for a board nobody has configured yet, and a broken ' +
362
+ 'board the moment registration asks anybody to confirm an address.',
363
+ schema: z.enum(['log', 'http', 'smtp']),
364
+ default: 'log',
365
+ ui: {
366
+ options: [
367
+ { value: 'log', label: 'Not sending (log only)' },
368
+ { value: 'smtp', label: 'SMTP server' },
369
+ { value: 'http', label: 'Provider API (Resend-compatible JSON)' },
370
+ ],
371
+ },
372
+ }),
373
+ define({
374
+ key: 'mail.from',
375
+ group: 'mail',
376
+ label: 'Sender address',
377
+ description:
378
+ 'The address every message comes from. It must be on a domain the ' +
379
+ 'provider has verified — a sender the provider does not recognise is ' +
380
+ 'rejected outright, and the rejection is not retried because it would ' +
381
+ 'fail identically every time.',
382
+ schema: z
383
+ .string()
384
+ .trim()
385
+ .refine(
386
+ (value) => value === '' || z.string().email().safeParse(value).success,
387
+ 'That does not look like an e-mail address.',
388
+ ),
389
+ default: '',
390
+ }),
391
+ define({
392
+ key: 'mail.from_name',
393
+ group: 'mail',
394
+ label: 'Sender name',
395
+ description: 'Display name shown beside the sender address. Empty sends the bare ' + 'address.',
396
+ schema: z.string().max(100),
397
+ default: '',
398
+ }),
399
+ define({
400
+ key: 'mail.http_endpoint',
401
+ group: 'mail',
402
+ label: 'Provider API endpoint',
403
+ description:
404
+ 'Only for the provider-API transport. The board posts Resend’s exact ' +
405
+ 'field names with a Bearer token, so this works for Resend and for ' +
406
+ 'anything that copies it — and not for Postmark or Mailgun, whose SMTP ' +
407
+ 'hosts are the way in.',
408
+ schema: z
409
+ .string()
410
+ .trim()
411
+ .refine(
412
+ (value) => value === '' || z.string().url().safeParse(value).success,
413
+ 'That is not a URL.',
414
+ ),
415
+ default: '',
416
+ }),
417
+ define({
418
+ key: 'mail.http_token',
419
+ group: 'mail',
420
+ label: 'Provider API key',
421
+ description: 'Only for the provider-API transport. Stored on the board.',
422
+ schema: z.string().trim().max(500),
423
+ default: '',
424
+ secret: true,
425
+ }),
426
+ define({
427
+ key: 'mail.smtp_host',
428
+ group: 'mail',
429
+ label: 'SMTP host',
430
+ description: 'Only for the SMTP transport. For example smtp.resend.com.',
431
+ schema: z.string().trim().max(255),
432
+ default: '',
433
+ }),
434
+ define({
435
+ key: 'mail.smtp_port',
436
+ group: 'mail',
437
+ label: 'SMTP port',
438
+ description:
439
+ '465 for implicit TLS, 587 for STARTTLS. Picking the security mode below ' +
440
+ 'that does not match the port is the usual cause of a connection that ' +
441
+ 'hangs rather than failing.',
442
+ schema: z.number().int().min(1).max(65535),
443
+ default: 587,
444
+ ui: { min: 1, max: 65535 },
445
+ }),
446
+ define({
447
+ key: 'mail.smtp_security',
448
+ group: 'mail',
449
+ label: 'SMTP security',
450
+ description:
451
+ 'Implicit TLS encrypts the socket before the first byte (port 465). ' +
452
+ 'STARTTLS connects in the clear and upgrades, and the board refuses to ' +
453
+ 'continue if the upgrade fails (port 587). "None" is genuinely ' +
454
+ 'unencrypted and is for a relay on this machine and nothing else.',
455
+ schema: z.enum(['tls', 'starttls', 'none']),
456
+ default: 'starttls',
457
+ ui: {
458
+ options: [
459
+ { value: 'starttls', label: 'STARTTLS, required (port 587)' },
460
+ { value: 'tls', label: 'Implicit TLS (port 465)' },
461
+ { value: 'none', label: 'None — local relay only' },
462
+ ],
463
+ },
464
+ }),
465
+ define({
466
+ key: 'mail.smtp_username',
467
+ group: 'mail',
468
+ label: 'SMTP username',
469
+ description:
470
+ 'Empty only for a relay that does not authenticate. Resend wants the ' +
471
+ 'literal word “resend” here, with the API key as the password.',
472
+ schema: z.string().trim().max(255),
473
+ default: '',
474
+ }),
475
+ define({
476
+ key: 'mail.smtp_password',
477
+ group: 'mail',
478
+ label: 'SMTP password',
479
+ description:
480
+ 'Use an app password rather than the password you sign in with, wherever ' +
481
+ 'the provider offers one. Stored on the board.',
482
+ schema: z.string().trim().max(500),
483
+ default: '',
484
+ secret: true,
485
+ }),
486
+
487
+ define({
488
+ key: 'reputation.enabled',
489
+ group: 'reputation',
490
+ label: 'Reputation enabled',
491
+ description:
492
+ 'Members can rate each other. Off hides every control and every total; ' +
493
+ 'existing ratings are kept, so switching it back on restores them.',
494
+ schema: z.boolean(),
495
+ default: true,
496
+ invalidates: ['settings'],
497
+ }),
498
+ define({
499
+ key: 'reputation.allow_negative',
500
+ group: 'reputation',
501
+ label: 'Allow negative ratings',
502
+ description:
503
+ 'Members can rate somebody down as well as up. Off makes reputation a ' +
504
+ 'thanks button, which is what most boards actually want — and takes the ' +
505
+ 'Rate link off posts, since the Thanks button on each one is then the ' +
506
+ 'whole of what the rating form could offer.',
507
+ schema: z.boolean(),
508
+ default: false,
509
+ invalidates: ['settings'],
510
+ }),
511
+ define({
512
+ key: 'reputation.comment_required',
513
+ group: 'reputation',
514
+ label: 'Require a comment',
515
+ description:
516
+ 'A rating must say why. A number with no reason attached is the part of ' +
517
+ 'reputation people argue about — but it also removes the one-press ' +
518
+ 'Thanks button from posts, because one press cannot carry a reason. Off ' +
519
+ 'by default: thanking an answer that helped should be a single click, ' +
520
+ 'and it is negative ratings that need explaining.',
521
+ schema: z.boolean(),
522
+ default: false,
523
+ invalidates: ['settings'],
524
+ }),
525
+ define({
526
+ key: 'reputation.min_posts_to_give',
527
+ group: 'reputation',
528
+ label: 'Posts required before rating',
529
+ description:
530
+ 'A brand-new account cannot rate anybody until it has posted this many ' +
531
+ 'times. 0 disables the requirement.',
532
+ schema: z.number().int().min(0).max(1000),
533
+ default: 5,
534
+ invalidates: ['settings'],
535
+ ui: { min: 0, max: 1000 },
536
+ }),
537
+
538
+ define({
539
+ key: 'security.session_idle_days',
540
+ group: 'security',
541
+ label: 'Session lifetime (days)',
542
+ description:
543
+ 'How long a sign-in lasts, counted from the moment it happened and not ' +
544
+ 'extended by use — a session in daily use still ends on its date. ' +
545
+ 'Separate from “keep me signed in”, which is a different token with a ' +
546
+ 'longer life of its own and does renew as it is used.',
547
+ schema: z.number().int().min(1).max(365),
548
+ default: 14,
549
+ ui: { min: 1, max: 365, advanced: true },
550
+ }),
551
+ define({
552
+ key: 'security.max_login_attempts',
553
+ group: 'security',
554
+ label: 'Failed login attempts before lockout',
555
+ description:
556
+ 'Counted per account per address, so somebody guessing at one account ' +
557
+ 'from one place meets this first. 0 disables it.',
558
+ schema: z.number().int().min(0).max(100),
559
+ default: 5,
560
+ ui: { min: 0, max: 100, advanced: true },
561
+ }),
562
+ define({
563
+ key: 'security.max_account_login_attempts',
564
+ group: 'security',
565
+ label: 'Failed login attempts before an account locks everywhere',
566
+ description:
567
+ 'The same count for one account across every address at once, which is ' +
568
+ 'what a guess spread over a botnet meets. Keep it well above the ' +
569
+ 'per-address number — it locks the real owner out too, which is the ' +
570
+ 'cost of it working at all. 0 disables it.',
571
+ schema: z.number().int().min(0).max(10_000),
572
+ default: 50,
573
+ ui: { min: 0, max: 10_000, advanced: true },
574
+ }),
575
+ define({
576
+ key: 'security.lockout_minutes',
577
+ group: 'security',
578
+ label: 'Lockout duration (minutes)',
579
+ description:
580
+ 'How long an account stays locked after too many failures. The window ' +
581
+ 'the counts above are measured over, and the one the per-address limit ' +
582
+ 'on the anti-spam screen shares.',
583
+ schema: z.number().int().min(1).max(10_080),
584
+ default: 15,
585
+ ui: { min: 1, max: 10_080, advanced: true },
586
+ }),
587
+
588
+ define({
589
+ key: 'antispam.captcha_mode',
590
+ group: 'antispam',
591
+ label: 'Registration challenge',
592
+ description:
593
+ 'Off, or a question you set. Swapping in a hosted captcha is a small ' +
594
+ 'amount of code against the provider seam, not a setting — see the ' +
595
+ 'plugin and anti-spam documentation.',
596
+ schema: z.enum(['off', 'question']),
597
+ default: 'off',
598
+ ui: {
599
+ options: [
600
+ { value: 'off', label: 'No challenge' },
601
+ { value: 'question', label: 'Ask a question' },
602
+ ],
603
+ },
604
+ }),
605
+ define({
606
+ key: 'antispam.honeypot',
607
+ group: 'antispam',
608
+ label: 'Hidden-field trap',
609
+ description:
610
+ 'Adds a field a person never sees and a bot fills in. Costs a real ' +
611
+ 'visitor nothing, and catches the least sophisticated half of them.',
612
+ schema: z.boolean(),
613
+ default: true,
614
+ }),
615
+ define({
616
+ key: 'antispam.min_form_seconds',
617
+ group: 'antispam',
618
+ label: 'Minimum seconds to fill the registration form',
619
+ description:
620
+ 'A form submitted faster than this is treated as automated. 0 disables ' +
621
+ 'the check. Keep it low — a password manager filling a form in two ' +
622
+ 'seconds is a real person.',
623
+ schema: z.number().int().min(0).max(120),
624
+ default: 3,
625
+ ui: { min: 0, max: 120 },
626
+ }),
627
+ define({
628
+ key: 'antispam.moderate_first_posts',
629
+ group: 'antispam',
630
+ label: 'Hold a new member’s first posts',
631
+ description:
632
+ 'Posts are held for approval until the account has this many. 0 ' +
633
+ 'disables it. The cheapest control a board has: spam accounts post ' +
634
+ 'once and never return.',
635
+ schema: z.number().int().min(0).max(50),
636
+ default: 0,
637
+ ui: { min: 0, max: 50 },
638
+ }),
639
+
640
+ define({
641
+ key: 'antispam.register_ip_per_hour',
642
+ group: 'antispam',
643
+ label: 'Registrations per hour from one address',
644
+ description:
645
+ 'Counted per /24 (or /48), so a household or an office shares one ' +
646
+ 'allowance. Independent of the challenge: a board with no captcha is ' +
647
+ 'the one that needs this most. 0 disables the limit.',
648
+ schema: z.number().int().min(0).max(10_000),
649
+ default: 10,
650
+ ui: { min: 0, max: 10_000 },
651
+ }),
652
+ define({
653
+ key: 'antispam.reset_per_hour',
654
+ group: 'antispam',
655
+ label: 'Password reset requests per hour per address',
656
+ description:
657
+ 'How many reset mails one e-mail address can be sent in an hour. ' +
658
+ 'Without it, the reset form is an e-mail cannon anybody can point at ' +
659
+ 'anybody. 0 disables the limit.',
660
+ schema: z.number().int().min(0).max(10_000),
661
+ default: 5,
662
+ ui: { min: 0, max: 10_000 },
663
+ }),
664
+ define({
665
+ key: 'antispam.reset_ip_per_hour',
666
+ group: 'antispam',
667
+ label: 'Password reset requests per hour from one address',
668
+ description:
669
+ 'The same limit counted per requesting /24, which is what stops one ' +
670
+ 'caller working through a list of e-mail addresses. 0 disables it.',
671
+ schema: z.number().int().min(0).max(10_000),
672
+ default: 20,
673
+ ui: { min: 0, max: 10_000 },
674
+ }),
675
+ define({
676
+ key: 'antispam.login_ip_attempts',
677
+ group: 'antispam',
678
+ label: 'Failed logins from one address before lockout',
679
+ description:
680
+ 'The per-account counters catch somebody guessing at one account. ' +
681
+ 'This one catches the opposite shape — a single guess against each of ' +
682
+ 'a thousand accounts — and shares the lockout window with them. ' +
683
+ '0 disables it.',
684
+ schema: z.number().int().min(0).max(10_000),
685
+ default: 100,
686
+ ui: { min: 0, max: 10_000, advanced: true },
687
+ }),
688
+
689
+ define({
690
+ key: 'antispam.post_per_hour',
691
+ group: 'antispam',
692
+ label: 'Posts per hour',
693
+ description:
694
+ 'Threads and replies together. 0 disables the limit. Members with ' +
695
+ '“bypass flood check” are exempt, as they are from the flood interval.',
696
+ schema: z.number().int().min(0).max(10_000),
697
+ default: 0,
698
+ ui: { min: 0, max: 10_000 },
699
+ }),
700
+ define({
701
+ key: 'antispam.search_per_hour',
702
+ group: 'antispam',
703
+ label: 'Searches per hour',
704
+ description: 'Searching is the most expensive thing a guest can do. 0 disables.',
705
+ schema: z.number().int().min(0).max(10_000),
706
+ default: 0,
707
+ ui: { min: 0, max: 10_000 },
708
+ }),
709
+ define({
710
+ key: 'antispam.message_per_hour',
711
+ group: 'antispam',
712
+ label: 'Private messages per hour',
713
+ description:
714
+ 'Counted per sender, not per recipient — one message to ten people is ' +
715
+ 'one send and ten deliveries.',
716
+ schema: z.number().int().min(0).max(10_000),
717
+ default: 0,
718
+ ui: { min: 0, max: 10_000 },
719
+ }),
720
+ define({
721
+ key: 'antispam.report_per_hour',
722
+ group: 'antispam',
723
+ label: 'Reports per hour',
724
+ description:
725
+ 'A limit on reporting is a limit on asking for help, so set it high ' +
726
+ 'enough that a member having a bad day is not silenced. 0 disables.',
727
+ schema: z.number().int().min(0).max(10_000),
728
+ default: 0,
729
+ ui: { min: 0, max: 10_000 },
730
+ }),
731
+ define({
732
+ key: 'antispam.upload_per_hour',
733
+ group: 'antispam',
734
+ label: 'Uploads per hour',
735
+ description: 'Attachments and avatars. 0 disables the limit.',
736
+ schema: z.number().int().min(0).max(10_000),
737
+ default: 0,
738
+ ui: { min: 0, max: 10_000 },
739
+ }),
740
+
741
+ define({
742
+ key: 'security.two_factor_enabled',
743
+ group: 'security',
744
+ label: 'Offer two-factor authentication',
745
+ description:
746
+ 'Lets a member add an authenticator app to their account from ' +
747
+ '/usercp/security, and asks for a code after their password from then on. ' +
748
+ 'Needs AUTH_SECRET set, because the secret the app holds is sealed with a ' +
749
+ 'key derived from it.',
750
+ schema: z.boolean(),
751
+ default: true,
752
+ invalidates: ['settings'],
753
+ }),
754
+ define({
755
+ key: 'security.two_factor_required_for_staff',
756
+ group: 'security',
757
+ label: 'Require it of anyone who can reach the control panel',
758
+ description:
759
+ 'An account that can open the control panel is asked to set up an ' +
760
+ 'authenticator app before it can be used, and cannot turn it off again ' +
761
+ 'while it holds that access. The control panel can rewrite the whole ' +
762
+ 'board, so a password on its own is a thin thing to protect it with.',
763
+ schema: z.boolean(),
764
+ default: false,
765
+ invalidates: ['settings'],
766
+ }),
767
+
768
+ define({
769
+ key: 'security.auth_event_retention_days',
770
+ group: 'security',
771
+ label: 'Days of sign-in activity to keep',
772
+ description:
773
+ 'How long the sign-in activity log holds an entry before the hourly prune ' +
774
+ 'drops it. 0 keeps every entry forever, which is the default: an audit ' +
775
+ 'trail that deletes itself without being asked is one you cannot rely on. ' +
776
+ 'Set a number of days where a retention policy says you must.',
777
+ schema: z.number().int().min(0).max(3650),
778
+ default: 0,
779
+ ui: { min: 0, max: 3650 },
780
+ }),
781
+
782
+ define({
783
+ key: 'federation.github_enabled',
784
+ group: 'federation',
785
+ label: 'Sign in with GitHub',
786
+ description:
787
+ 'Off by default, and off is a position: turning it on means GitHub is told ' +
788
+ 'the address of this board every time somebody signs in with it, and gets ' +
789
+ 'to see who your members are. Needs the client ID and secret below, from a ' +
790
+ 'GitHub OAuth app whose callback URL is your board address followed by ' +
791
+ '/auth/sso/github/callback.',
792
+ schema: z.boolean(),
793
+ default: false,
794
+ invalidates: ['settings', 'layout'],
795
+ }),
796
+ define({
797
+ key: 'federation.github_client_id',
798
+ group: 'federation',
799
+ label: 'GitHub client ID',
800
+ description: 'From the OAuth app you registered with GitHub.',
801
+ schema: z.string().trim().max(200),
802
+ default: '',
803
+ invalidates: ['settings', 'layout'],
804
+ }),
805
+ define({
806
+ key: 'federation.github_client_secret',
807
+ group: 'federation',
808
+ label: 'GitHub client secret',
809
+ description:
810
+ 'Stored as written and never shown again. Leave the box empty to keep the ' +
811
+ 'secret already saved.',
812
+ schema: z.string().max(400),
813
+ default: '',
814
+ secret: true,
815
+ invalidates: ['settings', 'layout'],
816
+ }),
817
+
818
+ define({
819
+ key: 'federation.google_enabled',
820
+ group: 'federation',
821
+ label: 'Sign in with Google',
822
+ description:
823
+ 'Off by default. Turning it on means Google meets your members: it learns ' +
824
+ 'this board’s address and who signs in with it. The callback URL to give ' +
825
+ 'Google is your board address followed by /auth/sso/google/callback.',
826
+ schema: z.boolean(),
827
+ default: false,
828
+ invalidates: ['settings', 'layout'],
829
+ }),
830
+ define({
831
+ key: 'federation.google_client_id',
832
+ group: 'federation',
833
+ label: 'Google client ID',
834
+ description: 'From the OAuth 2.0 client you created in the Google Cloud console.',
835
+ schema: z.string().trim().max(200),
836
+ default: '',
837
+ invalidates: ['settings', 'layout'],
838
+ }),
839
+ define({
840
+ key: 'federation.google_client_secret',
841
+ group: 'federation',
842
+ label: 'Google client secret',
843
+ description:
844
+ 'Stored as written and never shown again. Leave the box empty to keep the ' +
845
+ 'secret already saved.',
846
+ schema: z.string().max(400),
847
+ default: '',
848
+ secret: true,
849
+ invalidates: ['settings', 'layout'],
850
+ }),
851
+
852
+ define({
853
+ key: 'federation.oidc_enabled',
854
+ group: 'federation',
855
+ label: 'Sign in with your own OpenID provider',
856
+ description:
857
+ 'The generic path, and the one an organisation usually wants: any provider ' +
858
+ 'that publishes an OpenID Connect discovery document — Entra ID, Okta, ' +
859
+ 'Keycloak, Authentik, your own. Off by default. The callback URL is your ' +
860
+ 'board address followed by /auth/sso/oidc/callback.',
861
+ schema: z.boolean(),
862
+ default: false,
863
+ invalidates: ['settings', 'layout'],
864
+ }),
865
+ define({
866
+ key: 'federation.oidc_label',
867
+ group: 'federation',
868
+ label: 'What to call it on the sign-in page',
869
+ description:
870
+ 'The button reads “Continue with …” and this fills the gap. Left empty it ' +
871
+ 'says “Single sign-on”, which tells a member nothing about which login they ' +
872
+ 'are being sent to.',
873
+ schema: z.string().trim().max(60),
874
+ default: '',
875
+ invalidates: ['settings', 'layout'],
876
+ }),
877
+ define({
878
+ key: 'federation.oidc_issuer',
879
+ group: 'federation',
880
+ label: 'Issuer URL',
881
+ description:
882
+ 'The issuer, with no trailing slash and no path of its own — the board asks ' +
883
+ 'it for /.well-known/openid-configuration and reads every endpoint from ' +
884
+ 'there, so nothing else needs configuring.',
885
+ schema: z
886
+ .string()
887
+ .trim()
888
+ .refine(
889
+ (value) => value === '' || isUsableIssuer(value),
890
+ 'Give the absolute https address of the issuer — https://login.example.com.',
891
+ ),
892
+ default: '',
893
+ invalidates: ['settings', 'layout'],
894
+ }),
895
+ define({
896
+ key: 'federation.oidc_client_id',
897
+ group: 'federation',
898
+ label: 'OpenID client ID',
899
+ description: 'The client this board is registered as with that provider.',
900
+ schema: z.string().trim().max(200),
901
+ default: '',
902
+ invalidates: ['settings', 'layout'],
903
+ }),
904
+ define({
905
+ key: 'federation.oidc_client_secret',
906
+ group: 'federation',
907
+ label: 'OpenID client secret',
908
+ description:
909
+ 'Stored as written and never shown again. Leave the box empty to keep the ' +
910
+ 'secret already saved.',
911
+ schema: z.string().max(400),
912
+ default: '',
913
+ secret: true,
914
+ invalidates: ['settings', 'layout'],
915
+ }),
916
+ define({
917
+ key: 'federation.oidc_scopes',
918
+ group: 'federation',
919
+ label: 'Scopes to ask for',
920
+ description:
921
+ 'Space-separated. `openid` is always sent whether or not it is here. The ' +
922
+ 'default asks for the address and the display name, which is the least this ' +
923
+ 'board can register an account from.',
924
+ schema: z.string().trim().max(300),
925
+ default: 'openid email profile',
926
+ invalidates: ['settings'],
927
+ ui: { advanced: true },
928
+ }),
929
+
930
+ define({
931
+ key: 'federation.passkeys_enabled',
932
+ group: 'federation',
933
+ label: 'Allow passkeys',
934
+ description:
935
+ 'Lets a member add a passkey from their account security page and sign in ' +
936
+ 'with it afterwards — a fingerprint, a face, or a security key, with no ' +
937
+ 'password to phish. Nothing leaves this board: the passkey is created ' +
938
+ 'against your own address and no third party is involved. A member who has ' +
939
+ 'one keeps their password unless they clear it themselves.',
940
+ schema: z.boolean(),
941
+ default: false,
942
+ invalidates: ['settings', 'layout'],
943
+ }),
944
+
945
+ define({
946
+ key: 'federation.link_from_usercp',
947
+ group: 'federation',
948
+ label: 'Let members link and unlink their own sign-ins',
949
+ description:
950
+ 'On, a member manages their linked providers and passkeys from ' +
951
+ '/usercp/security. Off, existing links keep working and the page stops ' +
952
+ 'offering to change them — for a board where identity is decided by the ' +
953
+ 'organisation and not by the member.',
954
+ schema: z.boolean(),
955
+ default: true,
956
+ invalidates: ['settings'],
957
+ ui: { advanced: true },
958
+ }),
959
+
960
+ define({
961
+ key: 'push.enabled',
962
+ group: 'push',
963
+ label: 'Offer web push',
964
+ description:
965
+ 'Members can ask this board to push a notification to a device, and the ' +
966
+ 'notifications screen grows a button that subscribes the browser they are ' +
967
+ 'reading on. Needs the two keys below. Off, no device is asked and no ' +
968
+ 'notification leaves by that route; what is already stored on the board ' +
969
+ 'stays, so switching it back on resumes where it left off.',
970
+ schema: z.boolean(),
971
+ default: false,
972
+ invalidates: ['settings'],
973
+ }),
974
+ define({
975
+ key: 'push.vapid_public_key',
976
+ group: 'push',
977
+ label: 'VAPID public key',
978
+ description:
979
+ 'The board identifies itself to a push service with this key, and every ' +
980
+ 'browser stores it when it subscribes. Generate the pair with ' +
981
+ '`community push:keys`. Replacing it invalidates every subscription ' +
982
+ 'already stored, because the browsers hold the old one.',
983
+ schema: z.string().trim().max(200),
984
+ default: '',
985
+ invalidates: ['settings'],
986
+ }),
987
+ define({
988
+ key: 'push.vapid_private_key',
989
+ group: 'push',
990
+ label: 'VAPID private key',
991
+ description:
992
+ 'The other half of the pair, and the half that signs. Stored on the ' +
993
+ 'board. Leave the box empty to keep the key already saved.',
994
+ schema: z.string().trim().max(200),
995
+ default: '',
996
+ secret: true,
997
+ invalidates: ['settings'],
998
+ }),
999
+ define({
1000
+ key: 'push.contact',
1001
+ group: 'push',
1002
+ label: 'Contact for the push service',
1003
+ description:
1004
+ 'A mailto: or https: address a push service can use to reach you when ' +
1005
+ 'this board is sending it something it does not like. Left empty, the ' +
1006
+ 'board sends the mail-from address, and failing that its own address — ' +
1007
+ 'but only when that address is an https one. With none of the three, ' +
1008
+ 'nothing is pushed.',
1009
+ schema: z
1010
+ .string()
1011
+ .trim()
1012
+ .refine(
1013
+ (value) => value === '' || /^(?:mailto:.+@.+|https:\/\/.+)$/.test(value),
1014
+ 'Give a mailto: address or an https: URL.',
1015
+ ),
1016
+ default: '',
1017
+ invalidates: ['settings'],
1018
+ }),
1019
+ define({
1020
+ key: 'legal.terms',
1021
+ group: 'legal',
1022
+ label: 'Terms of service',
1023
+ description:
1024
+ 'Markdown, published at /terms and linked from the footer. Registration ' +
1025
+ 'asks the visitor to accept it before the account is created, so emptying ' +
1026
+ 'this box takes the page, the footer link and the checkbox away together. ' +
1027
+ 'What ships is a template written to be replaced: read it, make it say ' +
1028
+ 'what your community actually does, and take advice on it if it matters.',
1029
+ schema: z.string().max(40_000),
1030
+ default: DEFAULT_TERMS_OF_SERVICE,
1031
+ invalidates: ['settings', 'layout'],
1032
+ ui: { multiline: true },
1033
+ }),
1034
+ define({
1035
+ key: 'legal.privacy',
1036
+ group: 'legal',
1037
+ label: 'Privacy policy',
1038
+ description:
1039
+ 'Markdown, published at /privacy and linked from the footer. Emptied, the ' +
1040
+ 'page and the link go. It ships as a template describing what this ' +
1041
+ 'software does by default — your host, your mail provider and anything ' +
1042
+ 'you have added are yours to describe.',
1043
+ schema: z.string().max(40_000),
1044
+ default: DEFAULT_PRIVACY_POLICY,
1045
+ invalidates: ['settings', 'layout'],
1046
+ ui: { multiline: true },
1047
+ }),
1048
+ define({
1049
+ key: 'legal.rules',
1050
+ group: 'legal',
1051
+ label: 'Rules & FAQ',
1052
+ description:
1053
+ 'Markdown, published at /rules and linked from the footer. Left empty by ' +
1054
+ 'default — a board explains itself in its own terms, not a template. Once ' +
1055
+ 'published, the first-post onboarding guidance links new members to it.',
1056
+ schema: z.string().max(40_000),
1057
+ default: '',
1058
+ invalidates: ['settings', 'layout'],
1059
+ ui: { multiline: true },
1060
+ }),
1061
+
1062
+ define({
1063
+ key: 'marketplace.feed_url',
1064
+ group: 'marketplace',
1065
+ label: 'Catalog feed URL',
1066
+ description:
1067
+ 'Where the daily catalog fetch and the Browse tab’s Refresh button read the ' +
1068
+ 'marketplace feed from. The default is meith.dev’s own curated feed; point it at a ' +
1069
+ 'self-hosted mirror serving the same /v1.json shape to run a private catalog instead. ' +
1070
+ 'The board fetches this address itself — a member’s browser never does.',
1071
+ schema: z
1072
+ .string()
1073
+ .trim()
1074
+ .refine(isUsableFeedUrl, 'Give an https URL — or http to a loopback address, for testing.'),
1075
+ default: 'https://www.meith.dev/marketplace/v1.json',
1076
+ }),
1077
+ ] as const
1078
+
1079
+ export type SettingKey = (typeof SETTING_DEFINITIONS)[number]['key']
1080
+
1081
+ export const SETTING_DEFINITION_BY_KEY = new Map<string, SettingDefinition<unknown>>(
1082
+ SETTING_DEFINITIONS.map((d) => [d.key, d as SettingDefinition<unknown>]),
1083
+ )
1084
+
1085
+ export type SettingValue<K extends SettingKey> = Extract<
1086
+ (typeof SETTING_DEFINITIONS)[number],
1087
+ { key: K }
1088
+ >['default']