@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.
package/src/fields.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { SettingDefinition } from './definitions'
2
+
3
+ export interface SettingOption {
4
+ readonly value: string
5
+ readonly label: string
6
+ }
7
+
8
+ export type SettingField =
9
+ | { readonly kind: 'text' }
10
+ | { readonly kind: 'textarea' }
11
+ | { readonly kind: 'boolean' }
12
+ | { readonly kind: 'number'; readonly min?: number; readonly max?: number }
13
+ | { readonly kind: 'select'; readonly options: readonly SettingOption[] }
14
+ | { readonly kind: 'secret' }
15
+
16
+ export function settingField(definition: SettingDefinition): SettingField {
17
+ if (definition.secret === true) return { kind: 'secret' }
18
+
19
+ const ui = definition.ui
20
+ if (ui?.options !== undefined) return { kind: 'select', options: ui.options }
21
+
22
+ switch (typeof definition.default) {
23
+ case 'boolean':
24
+ return { kind: 'boolean' }
25
+ case 'number':
26
+ return {
27
+ kind: 'number',
28
+ ...(ui?.min === undefined ? {} : { min: ui.min }),
29
+ ...(ui?.max === undefined ? {} : { max: ui.max }),
30
+ }
31
+ default:
32
+ return ui?.multiline === true ? { kind: 'textarea' } : { kind: 'text' }
33
+ }
34
+ }
35
+
36
+ export function secretClearField(key: string): string {
37
+ return `${key}__clear`
38
+ }
39
+
40
+ export function coerceFormValue(
41
+ definition: SettingDefinition,
42
+ raw: string | undefined,
43
+ options: { readonly clear?: boolean } = {},
44
+ ): unknown {
45
+ if (typeof definition.default === 'boolean') return raw !== undefined && raw !== ''
46
+ if (definition.secret === true) {
47
+ if (options.clear === true) return definition.default
48
+ if (raw === undefined || raw === '') return undefined
49
+ }
50
+ if (raw === undefined) return undefined
51
+
52
+ if (typeof definition.default === 'number') {
53
+ const value = Number(raw)
54
+ return raw.trim() === '' || Number.isNaN(value) ? raw : value
55
+ }
56
+
57
+ return raw
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ export {
2
+ type BoardUrlEnvironment,
3
+ type BoardUrlResolution,
4
+ type BoardUrlSource,
5
+ isUsableOrigin,
6
+ normaliseOrigin,
7
+ resolveBoardUrl,
8
+ } from './board-url'
9
+ export {
10
+ isLogoKey,
11
+ isLogoScheme,
12
+ LOGO_SCHEMES,
13
+ type LogoScheme,
14
+ logoFormat,
15
+ logoPath,
16
+ } from './branding'
17
+ export {
18
+ SETTING_DEFINITION_BY_KEY,
19
+ SETTING_DEFINITIONS,
20
+ type SettingDefinition,
21
+ type SettingGroup,
22
+ type SettingKey,
23
+ type SettingValue,
24
+ } from './definitions'
25
+ export {
26
+ coerceFormValue,
27
+ type SettingField,
28
+ type SettingOption,
29
+ secretClearField,
30
+ settingField,
31
+ } from './fields'
32
+ export { DEFAULT_PRIVACY_POLICY, DEFAULT_TERMS_OF_SERVICE } from './legal'
33
+ export {
34
+ canSendMail,
35
+ defaultPort,
36
+ describeMailConfig,
37
+ type HttpMailConfig,
38
+ type LogMailConfig,
39
+ MAIL_PRESET_BY_ID,
40
+ MAIL_PRESETS,
41
+ type MailConfig,
42
+ type MailEnvironment,
43
+ type MailPreset,
44
+ type MailResolution,
45
+ type MailSecurity,
46
+ type MailSource,
47
+ type MailTransport,
48
+ mailConfigFromEnvironment,
49
+ mailConfigFromSettings,
50
+ mailConfigProblems,
51
+ NO_MAIL,
52
+ resolveMailConfig,
53
+ type SmtpMailConfig,
54
+ } from './mail'
55
+ export { isUsableFeedUrl, isUsableIssuer } from './origin'
56
+ export {
57
+ type PushConfig,
58
+ type PushProblem,
59
+ type PushResolution,
60
+ pushContact,
61
+ resolvePushConfig,
62
+ } from './push'
63
+ export {
64
+ type SaveResult,
65
+ type SettingsRepository,
66
+ SettingsSnapshot,
67
+ type SettingsSnapshotOptions,
68
+ saveSettings,
69
+ } from './store'
package/src/legal.ts ADDED
@@ -0,0 +1,117 @@
1
+ export const DEFAULT_TERMS_OF_SERVICE = `## The short version
2
+
3
+ This community is run by its administrators. These terms are the agreement
4
+ between them and you. By creating an account, and by posting, you accept them.
5
+
6
+ ## Your account
7
+
8
+ - Give a working e-mail address. It is how the board reaches you about your own
9
+ account, and how you get back in when you forget your password.
10
+ - Keep your password to yourself. Anything posted from your account is treated
11
+ as posted by you.
12
+ - One account per person, unless an administrator has agreed otherwise.
13
+ - Do not register on behalf of someone else, or claim to be someone you are not.
14
+
15
+ ## What you post
16
+
17
+ You keep the rights to what you write. By posting it here you allow this
18
+ community to store it and to show it to whoever can read the forum it is in,
19
+ for as long as the community keeps running.
20
+
21
+ Do not post anything that is unlawful where this board operates, that harasses
22
+ or threatens another person, that infringes someone else's copyright, that
23
+ exists to advertise, or that deliberately disrupts other people's discussions.
24
+
25
+ ## Moderation
26
+
27
+ Moderators may edit, move, hide or delete any post, lock any thread, and warn,
28
+ suspend or remove any account. They do not have to ask first, and a decision may
29
+ be made about the effect of a post rather than its intent.
30
+
31
+ If you think a decision was wrong, say so to the staff rather than in the
32
+ thread — that is the route that can actually change it.
33
+
34
+ ## Ending your membership
35
+
36
+ You may stop using the board at any time and may ask the administrators to close
37
+ your account. Closing an account does not automatically delete what you have
38
+ already posted: the discussions other members took part in stay readable. If you
39
+ need something specific removed, ask, and the administrators will decide.
40
+
41
+ ## Availability
42
+
43
+ The board is provided as it is. There is no guarantee that it stays up, that
44
+ nothing is lost, or that it will keep running indefinitely. Keep your own copy
45
+ of anything you cannot afford to lose.
46
+
47
+ ## Changes
48
+
49
+ These terms can change. Continuing to use the board after a change means you
50
+ accept the version in force at the time you use it.
51
+
52
+ ## Contact
53
+
54
+ Questions about these terms go to the board's administrators.`
55
+
56
+ export const DEFAULT_PRIVACY_POLICY = `## What this covers
57
+
58
+ How this community handles information about the people who use it. The
59
+ administrators of this board decide what happens to that information.
60
+
61
+ ## What is collected
62
+
63
+ - **What you give us.** Your username, e-mail address, password (stored only as
64
+ a hash, never as text anyone can read), and anything you choose to put in your
65
+ profile or your posts.
66
+ - **What using the board records.** The IP address a request came from, the time
67
+ of it, and the browser it announced itself as — kept so that abuse can be
68
+ traced and spam refused.
69
+ - **Cookies.** One for your session, so the board knows you are signed in, and
70
+ ones remembering your preferences. Signing out clears the session cookie.
71
+
72
+ ## What it is used for
73
+
74
+ Running the board: showing your posts to other members, sending the mail you
75
+ asked for, keeping accounts secure, and dealing with spam and abuse. Nothing
76
+ here is sold, and nothing is handed to advertisers.
77
+
78
+ ## What other people can see
79
+
80
+ Your username, your profile, your posts, and when you were last active are
81
+ visible to anyone who can read the forum in question — which, on a public forum,
82
+ means anyone at all, including search engines. Your e-mail address and your IP
83
+ address are not shown to other members; administrators and moderators can see
84
+ them as part of moderating.
85
+
86
+ ## Who else is involved
87
+
88
+ The board runs on servers rented from a hosting provider, and it may send mail
89
+ through a mail provider. Those providers handle the data needed to do their job,
90
+ and nothing further.
91
+
92
+ If this board offers a "sign in with…" button, choosing it tells that provider
93
+ you have an account here and hands this board the address and display name they
94
+ hold for you. Nothing else passes in either direction, and no member has to use
95
+ one: a password works the same as it always did. A passkey involves nobody but
96
+ you and this board — the key never leaves your device.
97
+
98
+ ## How long it is kept
99
+
100
+ Your account and your posts are kept while your account exists. Technical logs,
101
+ including IP addresses, are kept only as long as they are useful for security
102
+ and moderation, and then discarded.
103
+
104
+ ## What you can ask for
105
+
106
+ You can ask the administrators for a copy of what is held about you, for a
107
+ correction to it, or for your account to be closed. Closing an account does not
108
+ by itself delete posts other members replied to; ask if you need something
109
+ specific removed, and it will be considered.
110
+
111
+ ## Changes
112
+
113
+ This policy can change. The version on this page is the one in force.
114
+
115
+ ## Contact
116
+
117
+ Questions about your data go to the board's administrators.`
package/src/mail.ts ADDED
@@ -0,0 +1,281 @@
1
+ import type { SettingsSnapshot } from './store'
2
+
3
+ export type MailSecurity = 'tls' | 'starttls' | 'none'
4
+
5
+ export type MailTransport = 'log' | 'http' | 'smtp'
6
+
7
+ export interface LogMailConfig {
8
+ readonly transport: 'log'
9
+ }
10
+
11
+ export interface HttpMailConfig {
12
+ readonly transport: 'http'
13
+ readonly from: string
14
+ readonly endpoint: string
15
+ readonly token: string
16
+ }
17
+
18
+ export interface SmtpMailConfig {
19
+ readonly transport: 'smtp'
20
+ readonly from: string
21
+ readonly host: string
22
+ readonly port: number
23
+ readonly security: MailSecurity
24
+ readonly username: string
25
+ readonly password: string
26
+ }
27
+
28
+ export type MailConfig = LogMailConfig | HttpMailConfig | SmtpMailConfig
29
+
30
+ export const NO_MAIL: LogMailConfig = { transport: 'log' }
31
+
32
+ export interface MailEnvironment {
33
+ readonly MAIL_DRIVER?: MailTransport | undefined
34
+ readonly MAIL_FROM?: string | undefined
35
+ readonly MAIL_HTTP_ENDPOINT?: string | undefined
36
+ readonly MAIL_HTTP_TOKEN?: string | undefined
37
+ readonly MAIL_SMTP_HOST?: string | undefined
38
+ readonly MAIL_SMTP_PORT?: number | undefined
39
+ readonly MAIL_SMTP_SECURITY?: MailSecurity | undefined
40
+ readonly MAIL_SMTP_USERNAME?: string | undefined
41
+ readonly MAIL_SMTP_PASSWORD?: string | undefined
42
+ }
43
+
44
+ export type MailSource = 'environment' | 'board'
45
+
46
+ export interface MailResolution {
47
+ readonly config: MailConfig
48
+ readonly source: MailSource
49
+ readonly problems: readonly string[]
50
+ }
51
+
52
+ function text(value: string | undefined): string {
53
+ return (value ?? '').trim()
54
+ }
55
+
56
+ export function mailConfigFromEnvironment(source: MailEnvironment): MailConfig | null {
57
+ switch (source.MAIL_DRIVER) {
58
+ case 'http':
59
+ return {
60
+ transport: 'http',
61
+ from: text(source.MAIL_FROM),
62
+ endpoint: text(source.MAIL_HTTP_ENDPOINT),
63
+ token: text(source.MAIL_HTTP_TOKEN),
64
+ }
65
+ case 'smtp':
66
+ return {
67
+ transport: 'smtp',
68
+ from: text(source.MAIL_FROM),
69
+ host: text(source.MAIL_SMTP_HOST),
70
+ port: source.MAIL_SMTP_PORT ?? defaultPort(source.MAIL_SMTP_SECURITY ?? 'starttls'),
71
+ security: source.MAIL_SMTP_SECURITY ?? 'starttls',
72
+ username: text(source.MAIL_SMTP_USERNAME),
73
+ password: text(source.MAIL_SMTP_PASSWORD),
74
+ }
75
+ default:
76
+ return null
77
+ }
78
+ }
79
+
80
+ export function mailConfigFromSettings(settings: SettingsSnapshot): MailConfig {
81
+ switch (settings.get('mail.transport')) {
82
+ case 'http':
83
+ return {
84
+ transport: 'http',
85
+ from: text(settings.get('mail.from')),
86
+ endpoint: text(settings.get('mail.http_endpoint')),
87
+ token: text(settings.get('mail.http_token')),
88
+ }
89
+ case 'smtp':
90
+ return {
91
+ transport: 'smtp',
92
+ from: text(settings.get('mail.from')),
93
+ host: text(settings.get('mail.smtp_host')),
94
+ port: settings.get('mail.smtp_port'),
95
+ security: settings.get('mail.smtp_security') as MailSecurity,
96
+ username: text(settings.get('mail.smtp_username')),
97
+ password: text(settings.get('mail.smtp_password')),
98
+ }
99
+ default:
100
+ return NO_MAIL
101
+ }
102
+ }
103
+
104
+ export function defaultPort(security: MailSecurity): number {
105
+ return security === 'tls' ? 465 : 587
106
+ }
107
+
108
+ export function resolveMailConfig(input: {
109
+ readonly environment: MailEnvironment
110
+ readonly settings: SettingsSnapshot
111
+ }): MailResolution {
112
+ const fromEnvironment = mailConfigFromEnvironment(input.environment)
113
+ const config = fromEnvironment ?? mailConfigFromSettings(input.settings)
114
+
115
+ return {
116
+ config,
117
+ source: fromEnvironment === null ? 'board' : 'environment',
118
+ problems: mailConfigProblems(config),
119
+ }
120
+ }
121
+
122
+ export function mailConfigProblems(config: MailConfig): readonly string[] {
123
+ const problems: string[] = []
124
+
125
+ if (config.transport === 'log') return problems
126
+
127
+ if (config.from === '') {
128
+ problems.push('No sender address. Mail needs an address to come from.')
129
+ }
130
+
131
+ if (config.transport === 'http') {
132
+ if (config.endpoint === '') problems.push('No API endpoint.')
133
+ if (config.token === '') problems.push('No API key.')
134
+ }
135
+
136
+ if (config.transport === 'smtp') {
137
+ if (config.host === '') problems.push('No SMTP host.')
138
+ if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
139
+ problems.push('The SMTP port must be between 1 and 65535.')
140
+ }
141
+ if (config.username !== '' && config.password === '') {
142
+ problems.push('An SMTP username was given with no password.')
143
+ }
144
+ if (config.username === '' && config.password !== '') {
145
+ problems.push('An SMTP password was given with no username.')
146
+ }
147
+ }
148
+
149
+ return problems
150
+ }
151
+
152
+ export function canSendMail(config: MailConfig): boolean {
153
+ return config.transport !== 'log' && mailConfigProblems(config).length === 0
154
+ }
155
+
156
+ export function describeMailConfig(config: MailConfig): string {
157
+ switch (config.transport) {
158
+ case 'log':
159
+ return 'Not sending — messages are written to the server log'
160
+ case 'http':
161
+ return `HTTP API at ${hostOf(config.endpoint)}, from ${config.from || '(no address)'}`
162
+ case 'smtp':
163
+ return (
164
+ `SMTP to ${config.host || '(no host)'}:${config.port} (${config.security}), ` +
165
+ `from ${config.from || '(no address)'}`
166
+ )
167
+ }
168
+ }
169
+
170
+ function hostOf(endpoint: string): string {
171
+ try {
172
+ return new URL(endpoint).host
173
+ } catch {
174
+ return endpoint === '' ? '(no endpoint)' : endpoint
175
+ }
176
+ }
177
+
178
+ export interface MailPreset {
179
+ readonly id: string
180
+ readonly label: string
181
+ readonly transport: 'http' | 'smtp'
182
+ readonly note: string
183
+ readonly endpoint?: string
184
+ readonly host?: string
185
+ readonly port?: number
186
+ readonly security?: MailSecurity
187
+ readonly username?: string
188
+ }
189
+
190
+ export const MAIL_PRESETS: readonly MailPreset[] = [
191
+ {
192
+ id: 'mailbox',
193
+ label: 'A mailbox I already have (SMTP)',
194
+ transport: 'smtp',
195
+ note:
196
+ 'The least work by a distance, if you already receive mail on this domain: ' +
197
+ 'SPF and DKIM are set up already, so there are no DNS records to add. Use ' +
198
+ 'your provider’s SMTP host and an app password — never your login password.',
199
+ port: 465,
200
+ security: 'tls',
201
+ },
202
+ {
203
+ id: 'resend-http',
204
+ label: 'Resend (API)',
205
+ transport: 'http',
206
+ endpoint: 'https://api.resend.com/emails',
207
+ note:
208
+ 'Free for 3,000 messages a month. You must verify your sending domain with ' +
209
+ 'Resend first — until you do, a new account can only mail the address you ' +
210
+ 'signed up with, and every other message is rejected.',
211
+ },
212
+ {
213
+ id: 'resend-smtp',
214
+ label: 'Resend (SMTP)',
215
+ transport: 'smtp',
216
+ host: 'smtp.resend.com',
217
+ port: 465,
218
+ security: 'tls',
219
+ username: 'resend',
220
+ note:
221
+ 'The same service as the row above, over SMTP. The username is the literal ' +
222
+ 'word “resend” and the password is your API key. Verify your domain first.',
223
+ },
224
+ {
225
+ id: 'brevo',
226
+ label: 'Brevo (SMTP)',
227
+ transport: 'smtp',
228
+ host: 'smtp-relay.brevo.com',
229
+ port: 587,
230
+ security: 'starttls',
231
+ note:
232
+ 'The most generous free tier — around 300 messages a day. Independent ' +
233
+ 'deliverability testing puts it below Postmark and Resend, which matters ' +
234
+ 'most for password resets. Verify your domain first.',
235
+ },
236
+ {
237
+ id: 'postmark',
238
+ label: 'Postmark (SMTP)',
239
+ transport: 'smtp',
240
+ host: 'smtp.postmarkapp.com',
241
+ port: 587,
242
+ security: 'starttls',
243
+ note:
244
+ 'The best-delivering of these and the stingiest — 100 messages a month free, ' +
245
+ 'and an approval step before you can send at all. Username and password are ' +
246
+ 'both the server API token. Verify your domain first.',
247
+ },
248
+ {
249
+ id: 'ses',
250
+ label: 'Amazon SES (SMTP)',
251
+ transport: 'smtp',
252
+ port: 587,
253
+ security: 'starttls',
254
+ note:
255
+ 'The cheapest at volume and the most work to start: a new account is in a ' +
256
+ 'sandbox that can only mail verified addresses, and leaving it needs a ' +
257
+ 'support request. The host is email-smtp.<your-region>.amazonaws.com, and ' +
258
+ 'the credentials are SMTP credentials — not your AWS access keys.',
259
+ },
260
+ {
261
+ id: 'smtp',
262
+ label: 'Any other SMTP server',
263
+ transport: 'smtp',
264
+ port: 587,
265
+ security: 'starttls',
266
+ note: 'Anything that speaks SMTP, including a relay you run yourself.',
267
+ },
268
+ {
269
+ id: 'http',
270
+ label: 'Any other JSON API',
271
+ transport: 'http',
272
+ note:
273
+ 'Only works for a provider whose API takes Resend’s exact field names — ' +
274
+ 'from, to, subject, text, html, reply_to — with a Bearer token. Postmark and ' +
275
+ 'Mailgun do not; use their SMTP hosts instead.',
276
+ },
277
+ ]
278
+
279
+ export const MAIL_PRESET_BY_ID: ReadonlyMap<string, MailPreset> = new Map(
280
+ MAIL_PRESETS.map((preset) => [preset.id, preset]),
281
+ )
package/src/origin.ts ADDED
@@ -0,0 +1,52 @@
1
+ export function normaliseOrigin(value: string): string {
2
+ return value.trim().replace(/\/+$/, '')
3
+ }
4
+
5
+ export function isUsableIssuer(value: string): boolean {
6
+ let parsed: URL
7
+ try {
8
+ parsed = new URL(normaliseOrigin(value))
9
+ } catch {
10
+ return false
11
+ }
12
+
13
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost') return false
14
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
15
+ return parsed.hostname !== '' && parsed.search === '' && parsed.hash === ''
16
+ }
17
+
18
+ /**
19
+ * https, or plain http to a loopback address — the same allowance
20
+ * `allowedRedirectHosts` gives a plugin route, for the same reason: a test
21
+ * double never gets real TLS, and self-hosting a marketplace mirror should
22
+ * not have to fake a certificate to be pointed at from a dev or e2e board.
23
+ */
24
+ export function isUsableFeedUrl(value: string): boolean {
25
+ let parsed: URL
26
+ try {
27
+ parsed = new URL(value)
28
+ } catch {
29
+ return false
30
+ }
31
+
32
+ const hostname = parsed.hostname.toLowerCase()
33
+ const loopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]'
34
+ return parsed.protocol === 'https:' || (parsed.protocol === 'http:' && loopback)
35
+ }
36
+
37
+ export function isUsableOrigin(value: string): boolean {
38
+ let parsed: URL
39
+ try {
40
+ parsed = new URL(normaliseOrigin(value))
41
+ } catch {
42
+ return false
43
+ }
44
+
45
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
46
+ if (parsed.hostname === '') return false
47
+ return (
48
+ (parsed.pathname === '' || parsed.pathname === '/') &&
49
+ parsed.search === '' &&
50
+ parsed.hash === ''
51
+ )
52
+ }
package/src/push.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { type BoardUrlEnvironment, resolveBoardUrl } from './board-url'
2
+ import type { SettingsSnapshot } from './store'
3
+
4
+ export interface PushConfig {
5
+ readonly publicKey: string
6
+ readonly privateKey: string
7
+ readonly subject: string
8
+ }
9
+
10
+ export type PushProblem = 'disabled' | 'no-keys' | 'no-subject'
11
+
12
+ export type PushResolution =
13
+ | { readonly config: PushConfig; readonly problem: null }
14
+ | { readonly config: null; readonly problem: PushProblem }
15
+
16
+ export function pushContact(input: {
17
+ readonly environment: BoardUrlEnvironment
18
+ readonly settings: SettingsSnapshot
19
+ }): string {
20
+ const stated = input.settings.get('push.contact').trim()
21
+ if (stated !== '') return stated
22
+
23
+ const from = input.settings.get('mail.from').trim()
24
+ if (from !== '') return `mailto:${from}`
25
+
26
+ const { url } = resolveBoardUrl(input)
27
+ return url.startsWith('https://') ? url : ''
28
+ }
29
+
30
+ export function resolvePushConfig(input: {
31
+ readonly environment: BoardUrlEnvironment
32
+ readonly settings: SettingsSnapshot
33
+ }): PushResolution {
34
+ if (!input.settings.get('push.enabled')) return { config: null, problem: 'disabled' }
35
+
36
+ const publicKey = input.settings.get('push.vapid_public_key').trim()
37
+ const privateKey = input.settings.get('push.vapid_private_key').trim()
38
+ if (publicKey === '' || privateKey === '') return { config: null, problem: 'no-keys' }
39
+
40
+ const subject = pushContact(input)
41
+ if (subject === '') return { config: null, problem: 'no-subject' }
42
+
43
+ return { config: { publicKey, privateKey, subject }, problem: null }
44
+ }