@crombolo/server 0.16.0 → 0.18.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/package.json +12 -12
- package/src/adapters/documents.ts +9 -2
- package/src/adapters/users.ts +45 -3
- package/src/approvals.ts +10 -0
- package/src/components.ts +77 -1
- package/src/config.ts +82 -81
- package/src/content-notifications.ts +310 -0
- package/src/email-ses.ts +4 -3
- package/src/email-text.ts +61 -0
- package/src/email.ts +1 -0
- package/src/form-files.ts +3 -0
- package/src/form-workflows.ts +159 -8
- package/src/index.ts +3 -0
- package/src/jobs.ts +36 -4
- package/src/ports.ts +33 -0
- package/src/server.ts +40 -1
- package/src/settings.ts +921 -0
package/src/settings.ts
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every environment variable crombolo reads, declared once. `loadConfig` reads
|
|
3
|
+
* through `setting`, and the configuration reference is generated from the list.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type SettingKind = 'string' | 'number' | 'boolean' | 'list' | 'enum'
|
|
7
|
+
|
|
8
|
+
export interface Setting {
|
|
9
|
+
env: string
|
|
10
|
+
kind: SettingKind
|
|
11
|
+
/** As a reader would see it; omitted when there is none. */
|
|
12
|
+
default?: string
|
|
13
|
+
/** The accepted values, for an enum. */
|
|
14
|
+
values?: readonly string[]
|
|
15
|
+
section: string
|
|
16
|
+
/** One or two plain sentences for a site owner. */
|
|
17
|
+
description: string
|
|
18
|
+
/** Never echoed. */
|
|
19
|
+
secret?: boolean
|
|
20
|
+
/** Who reads it; the server when unset. */
|
|
21
|
+
appliesTo?: 'server' | 'cli' | 'both'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SECTIONS = [
|
|
25
|
+
'Server',
|
|
26
|
+
'Database',
|
|
27
|
+
'Sign-in and secrets',
|
|
28
|
+
'Back office and bundles',
|
|
29
|
+
'Schema',
|
|
30
|
+
'Components',
|
|
31
|
+
'Media',
|
|
32
|
+
'E-mail',
|
|
33
|
+
'Members',
|
|
34
|
+
'Public site',
|
|
35
|
+
'Remote',
|
|
36
|
+
'Logging',
|
|
37
|
+
'Command line',
|
|
38
|
+
'AWS credentials',
|
|
39
|
+
] as const
|
|
40
|
+
|
|
41
|
+
/** Section headings, in display order. */
|
|
42
|
+
export const SETTING_SECTIONS: readonly string[] = SECTIONS
|
|
43
|
+
|
|
44
|
+
type Section = (typeof SECTIONS)[number]
|
|
45
|
+
|
|
46
|
+
const DECLARED = [
|
|
47
|
+
// Server
|
|
48
|
+
{
|
|
49
|
+
env: 'NODE_ENV',
|
|
50
|
+
kind: 'string',
|
|
51
|
+
section: 'Server',
|
|
52
|
+
description:
|
|
53
|
+
'`production` turns off the development conveniences: generated admin passwords, writable schema files, e-mail to the console and relaxed upgrade checks.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
env: 'PORT',
|
|
57
|
+
kind: 'number',
|
|
58
|
+
default: '8080',
|
|
59
|
+
section: 'Server',
|
|
60
|
+
description: 'The HTTP port. The application URL defaults to localhost on this port.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
env: 'CROMBOLO_SITE_NAME',
|
|
64
|
+
kind: 'string',
|
|
65
|
+
default: 'Crombolo',
|
|
66
|
+
section: 'Server',
|
|
67
|
+
description: "The site's name, shown in the back office and used when a page is shared.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
env: 'CROMBOLO_APPLICATION_URL',
|
|
71
|
+
kind: 'string',
|
|
72
|
+
default: 'http://localhost:<port>',
|
|
73
|
+
section: 'Server',
|
|
74
|
+
description: "The site's public address, which invitation and password-reset links point at.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
env: 'CROMBOLO_ROLE',
|
|
78
|
+
kind: 'enum',
|
|
79
|
+
values: ['all', 'api', 'web'],
|
|
80
|
+
default: 'all',
|
|
81
|
+
section: 'Server',
|
|
82
|
+
description:
|
|
83
|
+
'What this process serves: everything, the back office and its API, or the public site alone. Splitting needs Postgres; a misspelt role stops the server.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
env: 'CROMBOLO_NODE_ID',
|
|
87
|
+
kind: 'string',
|
|
88
|
+
default: 'host:pid',
|
|
89
|
+
section: 'Server',
|
|
90
|
+
description:
|
|
91
|
+
"This node's name, stamped on schema syncs, content changes and the upgrade history.",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
env: 'CROMBOLO_MAX_REQUEST_BODY_MB',
|
|
95
|
+
kind: 'number',
|
|
96
|
+
default: '32',
|
|
97
|
+
section: 'Server',
|
|
98
|
+
description: 'The largest request body the server reads, media uploads included.',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
env: 'CROMBOLO_INSECURE_COOKIES',
|
|
102
|
+
kind: 'boolean',
|
|
103
|
+
default: 'false',
|
|
104
|
+
section: 'Server',
|
|
105
|
+
description:
|
|
106
|
+
'Drops the secure cookie requirements, for a plain-http host that is not localhost.',
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
// Database
|
|
110
|
+
{
|
|
111
|
+
env: 'CROMBOLO_DB',
|
|
112
|
+
kind: 'enum',
|
|
113
|
+
values: ['sqlite', 'postgres'],
|
|
114
|
+
default: 'sqlite',
|
|
115
|
+
section: 'Database',
|
|
116
|
+
description: 'Which database engine the site runs on.',
|
|
117
|
+
appliesTo: 'both',
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
env: 'CROMBOLO_SQLITE_FILE',
|
|
121
|
+
kind: 'string',
|
|
122
|
+
default: 'crombolo.sqlite',
|
|
123
|
+
section: 'Database',
|
|
124
|
+
description: 'The SQLite file, relative to the site, or `:memory:`.',
|
|
125
|
+
appliesTo: 'both',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
env: 'CROMBOLO_POSTGRES_URL',
|
|
129
|
+
kind: 'string',
|
|
130
|
+
section: 'Database',
|
|
131
|
+
description: 'The Postgres connection string. Required when the database is Postgres.',
|
|
132
|
+
secret: true,
|
|
133
|
+
appliesTo: 'both',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
env: 'DATABASE_URL',
|
|
137
|
+
kind: 'string',
|
|
138
|
+
section: 'Database',
|
|
139
|
+
description: 'Used as the Postgres connection string when `CROMBOLO_POSTGRES_URL` is unset.',
|
|
140
|
+
secret: true,
|
|
141
|
+
appliesTo: 'both',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
env: 'CROMBOLO_LOCK_TIMEOUT_SECONDS',
|
|
145
|
+
kind: 'number',
|
|
146
|
+
default: '15',
|
|
147
|
+
section: 'Database',
|
|
148
|
+
description: 'How long a content write waits for another before it gives up and answers 503.',
|
|
149
|
+
appliesTo: 'both',
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
env: 'CROMBOLO_PG_DUMP',
|
|
153
|
+
kind: 'string',
|
|
154
|
+
section: 'Database',
|
|
155
|
+
description:
|
|
156
|
+
'A shell command that backs up Postgres before a repair, an upgrade or a purge. It is given `CROMBOLO_BACKUP_STAMP` and `CROMBOLO_BACKUP_REASON`; without it those commands need `--backup-taken`.',
|
|
157
|
+
appliesTo: 'both',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
env: 'CROMBOLO_UPGRADE_POLICY',
|
|
161
|
+
kind: 'enum',
|
|
162
|
+
values: ['strict', 'pending-allowed'],
|
|
163
|
+
default: 'strict in production, else pending-allowed',
|
|
164
|
+
section: 'Database',
|
|
165
|
+
description: 'Whether findings that need a person stop an upgrade.',
|
|
166
|
+
appliesTo: 'both',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
env: 'CROMBOLO_VERSION_CLEANUP',
|
|
170
|
+
kind: 'boolean',
|
|
171
|
+
default: 'false',
|
|
172
|
+
section: 'Database',
|
|
173
|
+
description: 'Runs the hourly clean-up of old content versions.',
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
env: 'CROMBOLO_VERSION_CLEANUP_KEEP_ALL_DAYS',
|
|
177
|
+
kind: 'number',
|
|
178
|
+
default: '7',
|
|
179
|
+
section: 'Database',
|
|
180
|
+
description:
|
|
181
|
+
"The clean-up keeps every version newer than this many days. A content type's own policy wins.",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
env: 'CROMBOLO_VERSION_CLEANUP_KEEP_LATEST_DAYS',
|
|
185
|
+
kind: 'number',
|
|
186
|
+
default: '90',
|
|
187
|
+
section: 'Database',
|
|
188
|
+
description: "After that, it keeps each day's latest version for this many days.",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
env: 'CROMBOLO_AUDIT_RETENTION_DAYS',
|
|
192
|
+
kind: 'number',
|
|
193
|
+
default: '365',
|
|
194
|
+
section: 'Database',
|
|
195
|
+
description: 'How many days the audit log keeps an entry. `0` keeps them all.',
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// Sign-in and secrets
|
|
199
|
+
{
|
|
200
|
+
env: 'CROMBOLO_ADMIN_LOGIN',
|
|
201
|
+
kind: 'string',
|
|
202
|
+
default: 'admin@crombolo.local',
|
|
203
|
+
section: 'Sign-in and secrets',
|
|
204
|
+
description: 'The login of the first administrator.',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
env: 'CROMBOLO_ADMIN_PASSWORD',
|
|
208
|
+
kind: 'string',
|
|
209
|
+
default: 'generated in development',
|
|
210
|
+
section: 'Sign-in and secrets',
|
|
211
|
+
description:
|
|
212
|
+
"The first administrator's password, used only when the database is created. Unset in production, the site is set up in the browser.",
|
|
213
|
+
secret: true,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
env: 'CROMBOLO_SETUP_CODE',
|
|
217
|
+
kind: 'string',
|
|
218
|
+
section: 'Sign-in and secrets',
|
|
219
|
+
description:
|
|
220
|
+
'Guards "Set up your new site" in production: 16 or more characters, chosen by you. Remove it once the site is set up.',
|
|
221
|
+
secret: true,
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
env: 'CROMBOLO_API_CLIENT_ID',
|
|
225
|
+
kind: 'string',
|
|
226
|
+
section: 'Sign-in and secrets',
|
|
227
|
+
description:
|
|
228
|
+
'An API client an unattended install creates in the administrators group; it starts `crombolo-back-office-`. With it, no password administrator is created unless `CROMBOLO_ADMIN_PASSWORD` is set.',
|
|
229
|
+
appliesTo: 'both',
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
env: 'CROMBOLO_API_CLIENT_SECRET',
|
|
233
|
+
kind: 'string',
|
|
234
|
+
section: 'Sign-in and secrets',
|
|
235
|
+
description:
|
|
236
|
+
"That client's secret, set together with the id. It is used once, when the client is created.",
|
|
237
|
+
secret: true,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
env: 'CROMBOLO_LOCAL_ADMIN',
|
|
241
|
+
kind: 'boolean',
|
|
242
|
+
default: 'false',
|
|
243
|
+
section: 'Sign-in and secrets',
|
|
244
|
+
description:
|
|
245
|
+
'Creates the password administrator even when an API client is set. Honoured only on localhost.',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
env: 'CROMBOLO_SECRET_KEY',
|
|
249
|
+
kind: 'string',
|
|
250
|
+
section: 'Sign-in and secrets',
|
|
251
|
+
description:
|
|
252
|
+
"Seals secrets kept in the database, such as a webhook's headers and two-factor keys; 32 or more characters. Features that need it are not offered without it.",
|
|
253
|
+
secret: true,
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
env: 'CROMBOLO_USERNAME_IS_EMAIL',
|
|
257
|
+
kind: 'boolean',
|
|
258
|
+
default: 'true',
|
|
259
|
+
section: 'Sign-in and secrets',
|
|
260
|
+
description: "A back office user's login is their e-mail address.",
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
// Back office and bundles
|
|
264
|
+
{
|
|
265
|
+
env: 'CROMBOLO_BACKOFFICE_PATH',
|
|
266
|
+
kind: 'string',
|
|
267
|
+
default: '/crombolo',
|
|
268
|
+
section: 'Back office and bundles',
|
|
269
|
+
description: 'Where the back office is served.',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
env: 'CROMBOLO_CSS_DIR',
|
|
273
|
+
kind: 'string',
|
|
274
|
+
default: 'css',
|
|
275
|
+
section: 'Back office and bundles',
|
|
276
|
+
description: 'The stylesheets the Settings section edits, served at `/css/`.',
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
env: 'CROMBOLO_SCRIPTS_DIR',
|
|
280
|
+
kind: 'string',
|
|
281
|
+
default: 'scripts',
|
|
282
|
+
section: 'Back office and bundles',
|
|
283
|
+
description: 'The scripts the Settings section edits, served at `/scripts/`.',
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
env: 'CROMBOLO_APPLY_BUNDLE_CONTENT',
|
|
287
|
+
kind: 'boolean',
|
|
288
|
+
default: 'true',
|
|
289
|
+
section: 'Back office and bundles',
|
|
290
|
+
description:
|
|
291
|
+
'Installing a bundle from the Marketplace also applies the schema, components and content it carries.',
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
env: 'CROMBOLO_RUNTIME_INSTALLS',
|
|
295
|
+
kind: 'boolean',
|
|
296
|
+
default: 'true, false with a schema store',
|
|
297
|
+
section: 'Back office and bundles',
|
|
298
|
+
description:
|
|
299
|
+
'The Packages section installs and removes packages on the running site. Off, they are added to `package.json` and deployed.',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
env: 'CROMBOLO_MARKETPLACE_URL',
|
|
303
|
+
kind: 'string',
|
|
304
|
+
default: 'https://www.npmjs.com/search?q=keywords%3Acrombolo-bundle',
|
|
305
|
+
section: 'Back office and bundles',
|
|
306
|
+
description: 'Where the Marketplace sends someone browsing for bundles.',
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
env: 'CROMBOLO_PACKAGE_KEYWORD',
|
|
310
|
+
kind: 'string',
|
|
311
|
+
default: 'crombolo-bundle',
|
|
312
|
+
section: 'Back office and bundles',
|
|
313
|
+
description: 'The npm keyword a bundle publishes under to be found by the Marketplace.',
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
env: 'CROMBOLO_NPM_REGISTRY',
|
|
317
|
+
kind: 'string',
|
|
318
|
+
default: 'https://registry.npmjs.org',
|
|
319
|
+
section: 'Back office and bundles',
|
|
320
|
+
description: 'The registry the Marketplace searches, such as a mirror.',
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
// Schema
|
|
324
|
+
{
|
|
325
|
+
env: 'CROMBOLO_SCHEMA_DIR',
|
|
326
|
+
kind: 'string',
|
|
327
|
+
default: 'schema',
|
|
328
|
+
section: 'Schema',
|
|
329
|
+
description: 'Where the schema files live. Nothing is synced when the folder is absent.',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
env: 'CROMBOLO_SCHEMA_REVISION',
|
|
333
|
+
kind: 'string',
|
|
334
|
+
default: '0',
|
|
335
|
+
section: 'Schema',
|
|
336
|
+
description: 'Set by a deploy, to order deploys of the same schema version.',
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
env: 'CROMBOLO_SCHEMA_CACHE_DIR',
|
|
340
|
+
kind: 'string',
|
|
341
|
+
default: '<temp>/crombolo-schema',
|
|
342
|
+
section: 'Schema',
|
|
343
|
+
description: 'Where a schema kept in cloud storage is copied to when the server starts.',
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
env: 'CROMBOLO_SCHEMA_WRITABLE',
|
|
347
|
+
kind: 'boolean',
|
|
348
|
+
default: 'true unless production or with a schema store',
|
|
349
|
+
section: 'Schema',
|
|
350
|
+
description: 'Whether the back office may change the schema files.',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
env: 'CROMBOLO_SYNC_SCHEMA_AT_BOOT',
|
|
354
|
+
kind: 'boolean',
|
|
355
|
+
default: 'true',
|
|
356
|
+
section: 'Schema',
|
|
357
|
+
description:
|
|
358
|
+
'Syncs the schema files when the server starts. Turn it off when a deploy runs `crombolo types sync` itself.',
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
// Components
|
|
362
|
+
{
|
|
363
|
+
env: 'CROMBOLO_COMPONENTS_DIR',
|
|
364
|
+
kind: 'string',
|
|
365
|
+
default: 'components',
|
|
366
|
+
section: 'Components',
|
|
367
|
+
description: "Where the site's page views and components live.",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
env: 'CROMBOLO_COMPONENTS_CACHE_DIR',
|
|
371
|
+
kind: 'string',
|
|
372
|
+
default: '.crombolo/components',
|
|
373
|
+
section: 'Components',
|
|
374
|
+
description:
|
|
375
|
+
'Where components are copied so that an edit is picked up without a restart. Inside the site, and cleared when the server starts.',
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
env: 'CROMBOLO_COMPONENTS_GATE_MS',
|
|
379
|
+
kind: 'number',
|
|
380
|
+
default: '5000, 500 in development',
|
|
381
|
+
section: 'Components',
|
|
382
|
+
description: 'How often a node looks for changed components, in milliseconds.',
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
env: 'CROMBOLO_COMPONENTS_SWAP_MS',
|
|
386
|
+
kind: 'number',
|
|
387
|
+
default: '10000, 0 in development',
|
|
388
|
+
section: 'Components',
|
|
389
|
+
description:
|
|
390
|
+
'The least time between two changes being picked up, in milliseconds. A save made in the back office is never held back by it.',
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
env: 'CROMBOLO_COMPONENTS_GENERATION_LIMIT',
|
|
394
|
+
kind: 'number',
|
|
395
|
+
default: '250',
|
|
396
|
+
section: 'Components',
|
|
397
|
+
description:
|
|
398
|
+
'How many changes a node picks up before it stops taking more and keeps serving the last.',
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
env: 'CROMBOLO_COMPONENTS_KEEP',
|
|
402
|
+
kind: 'number',
|
|
403
|
+
default: '3, all in development',
|
|
404
|
+
section: 'Components',
|
|
405
|
+
description: 'How many earlier copies are kept on disk, for an instant return to one.',
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
env: 'CROMBOLO_RENDER_TIMEOUT_MS',
|
|
409
|
+
kind: 'number',
|
|
410
|
+
default: '10000',
|
|
411
|
+
section: 'Components',
|
|
412
|
+
description:
|
|
413
|
+
'How long a page may take to render, layouts included, before it fails, in milliseconds. `0` for no limit.',
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
// Media
|
|
417
|
+
{
|
|
418
|
+
env: 'CROMBOLO_MEDIA_DIR',
|
|
419
|
+
kind: 'string',
|
|
420
|
+
default: 'media',
|
|
421
|
+
section: 'Media',
|
|
422
|
+
description: 'Where uploaded files live on this disk, served at `/media/`.',
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
env: 'CROMBOLO_MEDIA_STORE',
|
|
426
|
+
kind: 'enum',
|
|
427
|
+
values: ['filesystem', 's3', 'azure'],
|
|
428
|
+
default: 'filesystem',
|
|
429
|
+
section: 'Media',
|
|
430
|
+
description: 'Where media files are kept. An unrecognised value falls back to the file system.',
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
env: 'CROMBOLO_MEDIA_PUBLIC_URL',
|
|
434
|
+
kind: 'string',
|
|
435
|
+
section: 'Media',
|
|
436
|
+
description:
|
|
437
|
+
'A CDN in front of the media folder: browsers are sent there instead of being served by this server.',
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
env: 'CROMBOLO_MEDIA_S3_BUCKET',
|
|
441
|
+
kind: 'string',
|
|
442
|
+
section: 'Media',
|
|
443
|
+
description: 'S3: the bucket.',
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
env: 'CROMBOLO_MEDIA_S3_REGION',
|
|
447
|
+
kind: 'string',
|
|
448
|
+
section: 'Media',
|
|
449
|
+
description: "S3: the bucket's region.",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
env: 'CROMBOLO_MEDIA_S3_ENDPOINT',
|
|
453
|
+
kind: 'string',
|
|
454
|
+
section: 'Media',
|
|
455
|
+
description: 'S3: the endpoint, for an S3-compatible store such as R2, Spaces or MinIO.',
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
env: 'CROMBOLO_MEDIA_S3_ACCESS_KEY_ID',
|
|
459
|
+
kind: 'string',
|
|
460
|
+
section: 'Media',
|
|
461
|
+
description: "S3: the access key. Unset, the client's usual credentials are used.",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
env: 'CROMBOLO_MEDIA_S3_SECRET_ACCESS_KEY',
|
|
465
|
+
kind: 'string',
|
|
466
|
+
section: 'Media',
|
|
467
|
+
description: "S3: the access key's secret.",
|
|
468
|
+
secret: true,
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
env: 'CROMBOLO_MEDIA_S3_SESSION_TOKEN',
|
|
472
|
+
kind: 'string',
|
|
473
|
+
section: 'Media',
|
|
474
|
+
description: 'S3: a session token, for temporary credentials.',
|
|
475
|
+
secret: true,
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
env: 'CROMBOLO_MEDIA_S3_PREFIX',
|
|
479
|
+
kind: 'string',
|
|
480
|
+
section: 'Media',
|
|
481
|
+
description: 'S3: a folder inside the bucket to keep media under.',
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
env: 'CROMBOLO_MEDIA_S3_PUBLIC_URL',
|
|
485
|
+
kind: 'string',
|
|
486
|
+
section: 'Media',
|
|
487
|
+
description: 'S3: a public address for the bucket, such as a CDN, that browsers are sent to.',
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
env: 'CROMBOLO_MEDIA_S3_PRESIGN_SECONDS',
|
|
491
|
+
kind: 'number',
|
|
492
|
+
section: 'Media',
|
|
493
|
+
description:
|
|
494
|
+
'S3: sends browsers to a signed bucket address that lasts this many seconds. Use this or a public address, not both.',
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
env: 'CROMBOLO_MEDIA_AZURE_ACCOUNT',
|
|
498
|
+
kind: 'string',
|
|
499
|
+
section: 'Media',
|
|
500
|
+
description: 'Azure: the storage account.',
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
env: 'CROMBOLO_MEDIA_AZURE_CONTAINER',
|
|
504
|
+
kind: 'string',
|
|
505
|
+
default: 'media',
|
|
506
|
+
section: 'Media',
|
|
507
|
+
description: 'Azure: the blob container.',
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
env: 'CROMBOLO_MEDIA_AZURE_KEY',
|
|
511
|
+
kind: 'string',
|
|
512
|
+
section: 'Media',
|
|
513
|
+
description: "Azure: the account's key.",
|
|
514
|
+
secret: true,
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
env: 'CROMBOLO_MEDIA_AZURE_SAS',
|
|
518
|
+
kind: 'string',
|
|
519
|
+
section: 'Media',
|
|
520
|
+
description: 'Azure: a shared access signature, instead of the key.',
|
|
521
|
+
secret: true,
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
env: 'CROMBOLO_MEDIA_AZURE_ENDPOINT',
|
|
525
|
+
kind: 'string',
|
|
526
|
+
section: 'Media',
|
|
527
|
+
description: 'Azure: the blob endpoint, for a sovereign cloud or a local emulator.',
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
env: 'CROMBOLO_MEDIA_AZURE_PREFIX',
|
|
531
|
+
kind: 'string',
|
|
532
|
+
section: 'Media',
|
|
533
|
+
description: 'Azure: a folder inside the container to keep media under.',
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
env: 'CROMBOLO_MEDIA_AZURE_PUBLIC_URL',
|
|
537
|
+
kind: 'string',
|
|
538
|
+
section: 'Media',
|
|
539
|
+
description:
|
|
540
|
+
'Azure: a public address for the container, such as a CDN, that browsers are sent to.',
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
// E-mail
|
|
544
|
+
{
|
|
545
|
+
env: 'CROMBOLO_EMAIL_PROVIDER',
|
|
546
|
+
kind: 'enum',
|
|
547
|
+
values: ['none', 'resend', 'postmark', 'ses', 'custom'],
|
|
548
|
+
default: 'none',
|
|
549
|
+
section: 'E-mail',
|
|
550
|
+
description:
|
|
551
|
+
'How e-mail is sent. Without one, invitations, password resets and form e-mails are unavailable; development prints messages to the console.',
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
env: 'CROMBOLO_EMAIL_FROM',
|
|
555
|
+
kind: 'string',
|
|
556
|
+
section: 'E-mail',
|
|
557
|
+
description: 'The sender address. Every provider needs it, and verifies its domain.',
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
env: 'CROMBOLO_EMAIL_FROM_NAME',
|
|
561
|
+
kind: 'string',
|
|
562
|
+
section: 'E-mail',
|
|
563
|
+
description: 'The name shown beside the sender address.',
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
env: 'CROMBOLO_EMAIL_API_KEY',
|
|
567
|
+
kind: 'string',
|
|
568
|
+
section: 'E-mail',
|
|
569
|
+
description:
|
|
570
|
+
"The provider's key: Resend's API key, Postmark's server token, or a bearer token for a custom endpoint.",
|
|
571
|
+
secret: true,
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
env: 'CROMBOLO_EMAIL_URL',
|
|
575
|
+
kind: 'string',
|
|
576
|
+
default: "the provider's own API",
|
|
577
|
+
section: 'E-mail',
|
|
578
|
+
description:
|
|
579
|
+
'The address messages are sent to. Required for a custom endpoint, an override for the others. Must be HTTPS unless it is on this machine.',
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
env: 'CROMBOLO_EMAIL_POSTMARK_STREAM',
|
|
583
|
+
kind: 'string',
|
|
584
|
+
default: 'outbound',
|
|
585
|
+
section: 'E-mail',
|
|
586
|
+
description: 'Postmark: the message stream to send on.',
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
env: 'CROMBOLO_EMAIL_SES_REGION',
|
|
590
|
+
kind: 'string',
|
|
591
|
+
default: 'AWS_REGION',
|
|
592
|
+
section: 'E-mail',
|
|
593
|
+
description: 'SES: the region to send from.',
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
env: 'CROMBOLO_EMAIL_SES_ACCESS_KEY_ID',
|
|
597
|
+
kind: 'string',
|
|
598
|
+
default: 'AWS_ACCESS_KEY_ID',
|
|
599
|
+
section: 'E-mail',
|
|
600
|
+
description: 'SES: the access key, when it differs from the standard AWS one.',
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
env: 'CROMBOLO_EMAIL_SES_SECRET_ACCESS_KEY',
|
|
604
|
+
kind: 'string',
|
|
605
|
+
default: 'AWS_SECRET_ACCESS_KEY',
|
|
606
|
+
section: 'E-mail',
|
|
607
|
+
description: "SES: that access key's secret.",
|
|
608
|
+
secret: true,
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
env: 'CROMBOLO_EMAIL_SES_CONFIGURATION_SET',
|
|
612
|
+
kind: 'string',
|
|
613
|
+
section: 'E-mail',
|
|
614
|
+
description: 'SES: the configuration set that governs sending.',
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
env: 'CROMBOLO_EMAIL_SES_ENDPOINT',
|
|
618
|
+
kind: 'string',
|
|
619
|
+
default: 'the regional SES address',
|
|
620
|
+
section: 'E-mail',
|
|
621
|
+
description: 'SES: a private endpoint to send through instead.',
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
// Members
|
|
625
|
+
{
|
|
626
|
+
env: 'CROMBOLO_ALLOW_MEMBER_REGISTRATION',
|
|
627
|
+
kind: 'boolean',
|
|
628
|
+
default: 'false',
|
|
629
|
+
section: 'Members',
|
|
630
|
+
description: 'Lets visitors register as members of the site.',
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
env: 'CROMBOLO_MEMBER_REGISTRATION_TYPE',
|
|
634
|
+
kind: 'string',
|
|
635
|
+
default: 'Member',
|
|
636
|
+
section: 'Members',
|
|
637
|
+
description: 'The member type a registration creates, by alias.',
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
env: 'CROMBOLO_MEMBER_REGISTRATION_GROUPS',
|
|
641
|
+
kind: 'list',
|
|
642
|
+
section: 'Members',
|
|
643
|
+
description: 'The member groups a registration joins, separated by commas.',
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
env: 'CROMBOLO_MEMBER_SESSION_MINUTES',
|
|
647
|
+
kind: 'number',
|
|
648
|
+
default: '20160',
|
|
649
|
+
section: 'Members',
|
|
650
|
+
description: "How long a member's sign-in lasts, in minutes. The default is 14 days.",
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
env: 'CROMBOLO_MAX_FAILED_PASSWORD_ATTEMPTS',
|
|
654
|
+
kind: 'number',
|
|
655
|
+
default: '5',
|
|
656
|
+
section: 'Members',
|
|
657
|
+
description: 'Failed member sign-ins before the member is locked out. `0` never locks out.',
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
// Public site
|
|
661
|
+
{
|
|
662
|
+
env: 'CROMBOLO_INDEXABLE',
|
|
663
|
+
kind: 'boolean',
|
|
664
|
+
default: 'true',
|
|
665
|
+
section: 'Public site',
|
|
666
|
+
description:
|
|
667
|
+
'Off keeps search engines out entirely, for a staging site: robots are refused, there is no sitemap and every page asks not to be indexed.',
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
env: 'CROMBOLO_SITEMAP',
|
|
671
|
+
kind: 'boolean',
|
|
672
|
+
default: 'true',
|
|
673
|
+
section: 'Public site',
|
|
674
|
+
description: 'Serves `/sitemap.xml`.',
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
env: 'CROMBOLO_TRACK_REDIRECTS',
|
|
678
|
+
kind: 'boolean',
|
|
679
|
+
default: 'true',
|
|
680
|
+
section: 'Public site',
|
|
681
|
+
description: 'Renaming or moving a page records a redirect from the address it had.',
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
env: 'CROMBOLO_DELIVERY_API',
|
|
685
|
+
kind: 'boolean',
|
|
686
|
+
default: 'false',
|
|
687
|
+
section: 'Public site',
|
|
688
|
+
description: 'Turns on the Delivery API, which serves content as JSON.',
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
env: 'CROMBOLO_DELIVERY_API_KEY',
|
|
692
|
+
kind: 'string',
|
|
693
|
+
section: 'Public site',
|
|
694
|
+
description:
|
|
695
|
+
'The key that unlocks a private Delivery API, and previews of unpublished content.',
|
|
696
|
+
secret: true,
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
env: 'CROMBOLO_DELIVERY_API_PUBLIC',
|
|
700
|
+
kind: 'boolean',
|
|
701
|
+
default: 'true',
|
|
702
|
+
section: 'Public site',
|
|
703
|
+
description: 'Off, every Delivery API request needs the key.',
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
env: 'CROMBOLO_DELIVERY_API_MEDIA',
|
|
707
|
+
kind: 'boolean',
|
|
708
|
+
default: 'false',
|
|
709
|
+
section: 'Public site',
|
|
710
|
+
description: 'The Delivery API serves media as well as content.',
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
// Remote
|
|
714
|
+
{
|
|
715
|
+
env: 'CROMBOLO_REMOTE_ENABLED',
|
|
716
|
+
kind: 'boolean',
|
|
717
|
+
default: 'false',
|
|
718
|
+
section: 'Remote',
|
|
719
|
+
description: 'Lets `crombolo pull` and `crombolo push` reach this site.',
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
env: 'CROMBOLO_REMOTE_ENVIRONMENT',
|
|
723
|
+
kind: 'string',
|
|
724
|
+
default: 'production',
|
|
725
|
+
section: 'Remote',
|
|
726
|
+
description:
|
|
727
|
+
'The name this site goes by for pull and push: lower case letters, digits and dashes.',
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
env: 'CROMBOLO_REMOTE_PROTECTED',
|
|
731
|
+
kind: 'boolean',
|
|
732
|
+
default: 'true',
|
|
733
|
+
section: 'Remote',
|
|
734
|
+
description: 'A push here needs the protected-push permission.',
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
env: 'CROMBOLO_REMOTE_APPROVALS',
|
|
738
|
+
kind: 'boolean',
|
|
739
|
+
default: 'false',
|
|
740
|
+
section: 'Remote',
|
|
741
|
+
description:
|
|
742
|
+
'A push to a protected site waits for someone else to approve it. Needs `CROMBOLO_SECRET_KEY`.',
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
env: 'CROMBOLO_REMOTE_MAX_UPLOAD_GB',
|
|
746
|
+
kind: 'number',
|
|
747
|
+
default: '50',
|
|
748
|
+
section: 'Remote',
|
|
749
|
+
description:
|
|
750
|
+
'The largest push or media file a client may upload, in gigabytes. Every other request keeps the usual body limit.',
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
// Logging
|
|
754
|
+
{
|
|
755
|
+
env: 'CROMBOLO_LOGS_DIR',
|
|
756
|
+
kind: 'string',
|
|
757
|
+
default: 'logs',
|
|
758
|
+
section: 'Logging',
|
|
759
|
+
description: 'Where log files are written, a file a day, read by the back office log viewer.',
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
env: 'CROMBOLO_LOG_LEVEL',
|
|
763
|
+
kind: 'enum',
|
|
764
|
+
values: ['trace', 'debug', 'info', 'warning', 'error', 'fatal'],
|
|
765
|
+
default: 'info',
|
|
766
|
+
section: 'Logging',
|
|
767
|
+
description: 'The least severe level logged.',
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
env: 'CROMBOLO_LOG_TO_CONSOLE',
|
|
771
|
+
kind: 'boolean',
|
|
772
|
+
default: 'true',
|
|
773
|
+
section: 'Logging',
|
|
774
|
+
description: 'Log events are also printed to the console.',
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
env: 'CROMBOLO_TRACE_REQUESTS',
|
|
778
|
+
kind: 'boolean',
|
|
779
|
+
default: 'false',
|
|
780
|
+
section: 'Logging',
|
|
781
|
+
description: 'Prints a line on the console as each request arrives and as it is answered.',
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
env: 'CROMBOLO_HEAP_INTERVAL_SECONDS',
|
|
785
|
+
kind: 'number',
|
|
786
|
+
default: '0',
|
|
787
|
+
section: 'Logging',
|
|
788
|
+
description:
|
|
789
|
+
'Every this many seconds, prints the memory in use and the requests in flight. `0` is off.',
|
|
790
|
+
},
|
|
791
|
+
|
|
792
|
+
// Command line
|
|
793
|
+
{
|
|
794
|
+
env: 'CROMBOLO_BOOT_ATTEMPTS',
|
|
795
|
+
kind: 'number',
|
|
796
|
+
default: '10',
|
|
797
|
+
section: 'Command line',
|
|
798
|
+
description:
|
|
799
|
+
'How many times `crombolo start` tries to start the site, waiting longer each time, before it exits.',
|
|
800
|
+
appliesTo: 'cli',
|
|
801
|
+
},
|
|
802
|
+
{
|
|
803
|
+
env: 'CROMBOLO_ACTOR',
|
|
804
|
+
kind: 'string',
|
|
805
|
+
default: 'the account running the command',
|
|
806
|
+
section: 'Command line',
|
|
807
|
+
description: 'Who the audit log records for a command, for a pipeline acting for someone.',
|
|
808
|
+
appliesTo: 'cli',
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
env: 'CROMBOLO_BUNDLE_PASSPHRASE',
|
|
812
|
+
kind: 'string',
|
|
813
|
+
section: 'Command line',
|
|
814
|
+
description: 'The passphrase for an encrypted content file, instead of being asked for it.',
|
|
815
|
+
secret: true,
|
|
816
|
+
appliesTo: 'cli',
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
env: 'CROMBOLO_DEBUG',
|
|
820
|
+
kind: 'boolean',
|
|
821
|
+
default: 'false',
|
|
822
|
+
section: 'Command line',
|
|
823
|
+
description: 'Adds the stack trace to an unexpected error.',
|
|
824
|
+
appliesTo: 'cli',
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
env: 'CROMBOLO_TEMPLATES_DIR',
|
|
828
|
+
kind: 'string',
|
|
829
|
+
default: 'the templates the command line ships',
|
|
830
|
+
section: 'Command line',
|
|
831
|
+
description: 'Where `crombolo init` finds its site templates, for trying a changed one.',
|
|
832
|
+
appliesTo: 'cli',
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
// AWS credentials
|
|
836
|
+
{
|
|
837
|
+
env: 'AWS_REGION',
|
|
838
|
+
kind: 'string',
|
|
839
|
+
section: 'AWS credentials',
|
|
840
|
+
description: 'The AWS region, for SES e-mail and the Bedrock assistant when they set none.',
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
env: 'AWS_PROFILE',
|
|
844
|
+
kind: 'string',
|
|
845
|
+
section: 'AWS credentials',
|
|
846
|
+
description: 'The AWS profile the Bedrock assistant signs in with.',
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
env: 'AWS_ACCESS_KEY_ID',
|
|
850
|
+
kind: 'string',
|
|
851
|
+
section: 'AWS credentials',
|
|
852
|
+
description: 'A static AWS access key, for SES e-mail and the Bedrock assistant.',
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
env: 'AWS_SECRET_ACCESS_KEY',
|
|
856
|
+
kind: 'string',
|
|
857
|
+
section: 'AWS credentials',
|
|
858
|
+
description: "That access key's secret.",
|
|
859
|
+
secret: true,
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
env: 'AWS_SESSION_TOKEN',
|
|
863
|
+
kind: 'string',
|
|
864
|
+
section: 'AWS credentials',
|
|
865
|
+
description: 'A session token, for temporary AWS credentials.',
|
|
866
|
+
secret: true,
|
|
867
|
+
},
|
|
868
|
+
] as const satisfies readonly (Setting & { section: Section })[]
|
|
869
|
+
|
|
870
|
+
export const SETTINGS: readonly Setting[] = DECLARED
|
|
871
|
+
|
|
872
|
+
/** The name of a declared setting. */
|
|
873
|
+
export type SettingName = (typeof DECLARED)[number]['env']
|
|
874
|
+
|
|
875
|
+
const NAMES: ReadonlySet<string> = new Set(SETTINGS.map((entry) => entry.env))
|
|
876
|
+
|
|
877
|
+
/** The raw environment value of a declared setting; unset is undefined. */
|
|
878
|
+
export function setting(name: SettingName): string | undefined {
|
|
879
|
+
if (!NAMES.has(name)) throw new Error(`${name} is not a declared setting.`)
|
|
880
|
+
return Bun.env[name]
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function cell(text: string): string {
|
|
884
|
+
return text.replaceAll('|', '\\|').replaceAll('\n', ' ')
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function shownDefault(entry: Setting): string {
|
|
888
|
+
if (entry.default === undefined) return '—'
|
|
889
|
+
return /\s/.test(entry.default) ? cell(entry.default) : `\`${cell(entry.default)}\``
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function shownKind(entry: Setting): string {
|
|
893
|
+
if (entry.kind === 'enum') return (entry.values ?? []).map((value) => `\`${value}\``).join(', ')
|
|
894
|
+
if (entry.kind === 'boolean') return '`true` / `false`'
|
|
895
|
+
return entry.kind
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function shownDescription(entry: Setting): string {
|
|
899
|
+
const notes: string[] = []
|
|
900
|
+
if (entry.appliesTo === 'cli') notes.push('Read by the command line.')
|
|
901
|
+
if (entry.appliesTo === 'both') notes.push('Read by the server and the command line.')
|
|
902
|
+
if (entry.secret) notes.push('Kept secret: never printed.')
|
|
903
|
+
return cell([entry.description, ...notes].join(' '))
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** The settings reference: a table per section, in display order. */
|
|
907
|
+
export function settingsMarkdown(): string {
|
|
908
|
+
const parts: string[] = []
|
|
909
|
+
for (const section of SETTING_SECTIONS) {
|
|
910
|
+
const entries = SETTINGS.filter((entry) => entry.section === section)
|
|
911
|
+
if (entries.length === 0) continue
|
|
912
|
+
parts.push(`### ${section}`, '')
|
|
913
|
+
parts.push('| Setting | Takes | Default | Description |', '| --- | --- | --- | --- |')
|
|
914
|
+
for (const entry of entries)
|
|
915
|
+
parts.push(
|
|
916
|
+
`| \`${entry.env}\` | ${shownKind(entry)} | ${shownDefault(entry)} | ${shownDescription(entry)} |`,
|
|
917
|
+
)
|
|
918
|
+
parts.push('')
|
|
919
|
+
}
|
|
920
|
+
return parts.join('\n').trimEnd()
|
|
921
|
+
}
|