create-kelpie 0.5.2 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-kelpie",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Scaffold a self-hosted Kelpie assembly. Run it with npm create kelpie.",
5
5
  "keywords": [
6
6
  "kelpie",
@@ -40,10 +40,15 @@ Then open <http://localhost:__WEB_PORT__/signup> and create an account.
40
40
  Passwords need at least 12 characters. Signup names your workspace, invites your
41
41
  team, and leaves you on People with a starter handbook in place.
42
42
 
43
- This default configuration doesn't send email. `EMAIL_PROVIDER=log` prints
44
- invitation and password-reset links to the API's log instead of mailing them;
45
- copy them from there to follow either flow. Set `EMAIL_PROVIDER=smtp` below to
46
- send them for real.
43
+ This install picks its transactional-mail sender through `EMAIL_PROVIDER` in
44
+ `.env`. Out of the box it is `log`, which writes invitation and password-reset
45
+ messages to the API's log instead of sending them; copy the link from there
46
+ to follow either flow locally. Set `EMAIL_PROVIDER=smtp` and fill in the
47
+ `SMTP_*` variables below to send them over SMTP through the built-in
48
+ `smtp-email` core module (no install needed). Other provider modules
49
+ (Resend, Postmark, …) register their own name; install one, add it to
50
+ `kelpie.config.ts`'s `modules:`, and set `EMAIL_PROVIDER` to that name to
51
+ use it instead.
47
52
 
48
53
  ## Adding a module
49
54
 
@@ -66,13 +71,13 @@ lists every problem at once.
66
71
  | `DATABASE_URL` | A `postgres://` or `postgresql://` connection string |
67
72
  | `APP_BASE_URL` | The address people reach this Kelpie on. Emailed links (verification, password reset, invitations) are built from it. In development that is the Vite dev server; a deployment sets its real origin |
68
73
  | `LOG_LEVEL` | `debug`, `info`, `warn`, or `error` |
69
- | `EMAIL_PROVIDER` | `log` or `smtp`. `log` writes invitations and resets to the log instead of sending them |
74
+ | `EMAIL_PROVIDER` | Name of the transactional-mail sender to use. `log` (built in) writes to the log instead of sending; `smtp` sends over SMTP through the built-in `smtp-email` core module; other names come from third-party provider modules |
70
75
  | `EMAIL_FROM` | The address transactional mail comes from |
71
- | `SMTP_HOST` | Required when `EMAIL_PROVIDER=smtp`. The mail server to connect to |
72
- | `SMTP_PORT` | Required when `EMAIL_PROVIDER=smtp`. The mail server's port |
73
- | `SMTP_SECURE` | Required when `EMAIL_PROVIDER=smtp`. `true` or `false`. Whether to connect over TLS from the start (typically port 465) rather than upgrading with STARTTLS (typically port 587 or 25) |
74
- | `SMTP_USER` | Required when `EMAIL_PROVIDER=smtp`. The SMTP username |
75
- | `SMTP_PASSWORD` | Required when `EMAIL_PROVIDER=smtp`. The SMTP password |
76
+ | `SMTP_HOST` | Read by the built-in `smtp-email` module when `EMAIL_PROVIDER=smtp`. The mail server to connect to |
77
+ | `SMTP_PORT` | Read by the built-in `smtp-email` module when `EMAIL_PROVIDER=smtp`. The mail server's port |
78
+ | `SMTP_SECURE` | Read by the built-in `smtp-email` module when `EMAIL_PROVIDER=smtp`. `true` or `false`. Whether to connect over TLS from the start (typically port 465) rather than upgrading with STARTTLS (typically port 587 or 25) |
79
+ | `SMTP_USER` | Read by the built-in `smtp-email` module when `EMAIL_PROVIDER=smtp`. The SMTP username |
80
+ | `SMTP_PASSWORD` | Read by the built-in `smtp-email` module when `EMAIL_PROVIDER=smtp`. The SMTP password |
76
81
  | `SECRET_ENCRYPTION_KEY` | 32 bytes of base64, generated for this project |
77
82
  | `SECRET_ENCRYPTION_KEY_PREVIOUS` | Optional. Set only while rotating the key above |
78
83
  | `WEBHOOK_DELIVERY_RETENTION_DAYS` | Optional, default 30 |
package/templates/env CHANGED
@@ -26,17 +26,22 @@ APP_BASE_URL=http://localhost:__WEB_PORT__
26
26
  # changing it needs the rotation procedure in README.md.
27
27
  SECRET_ENCRYPTION_KEY=__SECRET_ENCRYPTION_KEY__
28
28
 
29
- # `log` writes invitations and password resets to the log instead of sending
30
- # them. `smtp` sends for real, through the server and credentials below.
29
+ # EMAIL_PROVIDER picks a named sender the runtime resolves. `log` is a
30
+ # built-in that writes messages to the log instead of sending them; `smtp`
31
+ # comes from `@kelpie/module-smtp-email`, which reads the SMTP variables
32
+ # below. A different provider module (Resend, Postmark, …) registers its
33
+ # own name — set EMAIL_PROVIDER to that name to use it.
31
34
  EMAIL_PROVIDER=log
32
35
  EMAIL_FROM=__EMAIL_FROM__
33
36
 
34
- # Required only when EMAIL_PROVIDER=smtp; ignored otherwise. SMTP_SECURE is
35
- # "true" for a server that expects TLS from the first byte (usually port 465),
36
- # "false" for one you connect to in plain text and that upgrades with STARTTLS
37
- # (usually port 587 or 25).
38
- # SMTP_HOST=smtp.example.com
39
- # SMTP_PORT=587
40
- # SMTP_SECURE=false
41
- # SMTP_USER=
42
- # SMTP_PASSWORD=
37
+ # Read by `@kelpie/module-smtp-email` when EMAIL_PROVIDER=smtp. Ignored
38
+ # otherwise.
39
+ #
40
+ # SMTP_SECURE is "true" for a server that expects TLS from the first byte
41
+ # (usually port 465), "false" for one you connect to in plain text and that
42
+ # upgrades with STARTTLS (usually port 587 or 25).
43
+ SMTP_HOST=smtp.example.com
44
+ SMTP_PORT=587
45
+ SMTP_SECURE=false
46
+ SMTP_USER=
47
+ SMTP_PASSWORD=
@@ -1,21 +1,114 @@
1
- import { coreModules } from '@kelpie/server'
2
- import type { KelpieModule } from '@kelpie/server'
1
+ import {
2
+ appUrlConfigSchema,
3
+ coreModules,
4
+ defineKelpieConfig,
5
+ fromEnv,
6
+ secretEncryptionConfigSchema,
7
+ } from '@kelpie/server'
8
+ import { z } from 'zod'
3
9
 
4
10
  /**
5
- * The server module list, and the only place it is declared.
11
+ * Your Kelpie service's configuration, and the only place it is declared.
6
12
  *
7
- * Boot registers these in order, after resolving what each one requires. An
8
- * unknown id, an unmet dependency, or invalid module configuration stops boot
9
- * rather than starting a service that is missing a feature.
13
+ * Every leaf is either a literal, committed to git and locked in for this
14
+ * deployment, or `fromEnv(...)`, marking a leaf the environment fills in. Edit
15
+ * this file for anything that should not change per deploy, and set environment
16
+ * variables (in `.env`, `.env.local`, or your process manager) for anything
17
+ * that does (secrets, per-tier limits, per-environment URLs).
10
18
  *
11
- * Add a module by installing it and putting it in this array:
19
+ * `resolveKelpieConfig(config, process.env)` at boot walks this object,
20
+ * resolves markers, validates, and produces the typed `KelpieConfig` the app
21
+ * runs on. Nothing in the app reads `process.env` for these fields after boot.
12
22
  *
13
- * import { smtpEmail } from '@kelpie/module-smtp-email'
14
- *
15
- * export const modules: readonly KelpieModule[] = [...coreModules, smtpEmail]
16
- *
17
- * Removing one from `coreModules` is possible too, but core modules depend on
18
- * each other, so boot will tell you if you have taken out something another
19
- * module needs.
23
+ * Add a module by installing it and putting it in the `modules` array below.
24
+ * `coreModules` already includes the built-in `smtp-email` module; set
25
+ * `EMAIL_PROVIDER=log` to fall back to the log sender.
20
26
  */
21
- export const modules: readonly KelpieModule[] = [...coreModules]
27
+
28
+ const runtimeMode = z.enum(['development', 'test', 'production'])
29
+ const logLevel = z.enum(['debug', 'info', 'warn', 'error'])
30
+ const positiveInt = z.coerce.number().int().positive()
31
+ const nonNegativeInt = z.coerce.number().int().nonnegative()
32
+ const port = positiveInt.max(65535)
33
+ const postgresUrl = z.string().refine(
34
+ (value) => {
35
+ try {
36
+ const { protocol } = new URL(value)
37
+ return protocol === 'postgres:' || protocol === 'postgresql:'
38
+ } catch {
39
+ return false
40
+ }
41
+ },
42
+ { message: 'must be a postgres:// or postgresql:// connection string' },
43
+ )
44
+
45
+ export default defineKelpieConfig({
46
+ runtimeMode: fromEnv('NODE_ENV', runtimeMode),
47
+ port: fromEnv('PORT', port),
48
+ databaseUrl: fromEnv('DATABASE_URL', postgresUrl),
49
+
50
+ // Every log line writes to each entry in `destinations`. Stdout is the
51
+ // default. Add a destination by extending the `LoggingDestination` union in
52
+ // `@kelpie/server` and placing an entry here.
53
+ logging: {
54
+ level: fromEnv('LOG_LEVEL', logLevel),
55
+ destinations: [{ kind: 'stdout' }],
56
+ },
57
+
58
+ webBundleDirectory: fromEnv<string | undefined>('WEB_BUNDLE_DIR', z.string().min(1).optional(), undefined),
59
+ moduleConfigPath: fromEnv<string | undefined>('KELPIE_MODULE_CONFIG_PATH', z.string().min(1).optional(), undefined),
60
+ trustedProxyHopCount: fromEnv('TRUSTED_PROXY_HOP_COUNT', nonNegativeInt, 0),
61
+
62
+ // The deployment's base URL. Every emailed link (invite, password reset,
63
+ // email verification) is built from it.
64
+ appBaseUrl: fromEnv('APP_BASE_URL', appUrlConfigSchema.shape.APP_BASE_URL),
65
+
66
+ // Keys that seal stored secrets. Generate one with:
67
+ // node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
68
+ // To rotate, set the current key as `previousKey`, put the new key here,
69
+ // deploy, then run `npm run reseal`.
70
+ secretEncryption: {
71
+ key: fromEnv('SECRET_ENCRYPTION_KEY', secretEncryptionConfigSchema.shape.SECRET_ENCRYPTION_KEY),
72
+ previousKey: fromEnv<string | undefined>(
73
+ 'SECRET_ENCRYPTION_KEY_PREVIOUS',
74
+ secretEncryptionConfigSchema.shape.SECRET_ENCRYPTION_KEY_PREVIOUS,
75
+ undefined,
76
+ ),
77
+ },
78
+
79
+ // `provider` picks a named sender from the runtime's registry: `'log'` is
80
+ // built in and writes the message to the log instead of sending it. `'smtp'`
81
+ // is registered by the built-in `smtp-email` core module and reads the
82
+ // `SMTP_*` variables at boot when it is picked. Other names come from
83
+ // third-party provider modules (Resend, Postmark) added to `modules` below.
84
+ // `from` is the address on every outgoing message.
85
+ email: {
86
+ provider: fromEnv('EMAIL_PROVIDER', z.string().min(1)),
87
+ from: fromEnv('EMAIL_FROM', z.string().min(1)),
88
+ },
89
+
90
+ rateLimit: {
91
+ forms: {
92
+ limit: fromEnv('RATE_LIMIT_FORMS_LIMIT', positiveInt, 20),
93
+ windowSeconds: fromEnv('RATE_LIMIT_FORMS_WINDOW_SECONDS', positiveInt, 60),
94
+ },
95
+ auth: {
96
+ limit: fromEnv('RATE_LIMIT_AUTH_LIMIT', positiveInt, 10),
97
+ windowSeconds: fromEnv('RATE_LIMIT_AUTH_WINDOW_SECONDS', positiveInt, 60),
98
+ },
99
+ loginAccount: {
100
+ limit: fromEnv('RATE_LIMIT_LOGIN_ACCOUNT_LIMIT', positiveInt, 10),
101
+ windowSeconds: fromEnv('RATE_LIMIT_LOGIN_ACCOUNT_WINDOW_SECONDS', positiveInt, 900),
102
+ },
103
+ api: {
104
+ limit: fromEnv('RATE_LIMIT_API_LIMIT', positiveInt, 600),
105
+ windowSeconds: fromEnv('RATE_LIMIT_API_WINDOW_SECONDS', positiveInt, 60),
106
+ },
107
+ },
108
+
109
+ // `coreModules` already includes the built-in `smtp-email` module, which
110
+ // registers a `'smtp'` provider. Set `EMAIL_PROVIDER=log` to fall back to
111
+ // the built-in log sender; the `smtp-email` module only reads the SMTP
112
+ // environment when `email.provider` picks its name.
113
+ modules: [...coreModules],
114
+ })
@@ -1,101 +1,33 @@
1
1
  import {
2
2
  ConfigurationError,
3
- connectDatabase,
4
3
  createLogger,
5
- createSecretCipher,
6
- loadConfig,
7
- resealStoredSecrets,
8
- secretEncryptionConfigSchema,
4
+ createTransportForDestination,
5
+ resolveKelpieConfig,
6
+ runReseal,
9
7
  } from '@kelpie/server'
10
8
 
9
+ import kelpieConfig from '../kelpie.config.ts'
10
+
11
11
  /**
12
12
  * Re-seals stored secrets after `SECRET_ENCRYPTION_KEY` changes.
13
13
  *
14
- * The rotation procedure, in full:
15
- *
16
- * 1. Keep the current key. Add the new one as `SECRET_ENCRYPTION_KEY` and move
17
- * the old value to `SECRET_ENCRYPTION_KEY_PREVIOUS`.
18
- * 2. Deploy. Deliveries keep signing: new secrets seal under the new key, and
19
- * existing ones still open under the previous one.
20
- * 3. Run `npm run reseal`. It rewrites every row still sealed under the old key.
21
- * 4. Remove `SECRET_ENCRYPTION_KEY_PREVIOUS` and deploy again.
22
- *
23
- * Safe to run at any point, including with no previous key set, where it reports
24
- * that everything is already current and writes nothing.
25
- *
26
- * A script rather than a subcommand because the repository has no CLI surface
27
- * yet. When self-host packaging adds one, this becomes a subcommand of it and
28
- * the body moves across unchanged.
14
+ * The rotation procedure lives on `runReseal`'s JSDoc; this file only wires
15
+ * the assembly's config in and hands `ConfigurationError` a fix hint on the
16
+ * way out. A future `kelpie` CLI turns this into a subcommand and the body
17
+ * stays the same size.
29
18
  */
30
19
 
31
- function report(message: string): void {
32
- process.stdout.write(`${message}\n`)
33
- }
34
-
35
20
  function reportFatal(message: string): void {
36
21
  process.stderr.write(`${message}\n`)
37
22
  }
38
23
 
39
- async function reseal(): Promise<number> {
40
- const config = loadConfig(process.env)
41
- const logger = createLogger(config.logLevel)
42
- const database = connectDatabase(config.databaseUrl, logger)
43
-
44
- // Validated here rather than trusted, so a mistyped key fails before the pass
45
- // opens a single row instead of reporting every row unreadable.
46
- const secretConfig = secretEncryptionConfigSchema.safeParse(process.env)
47
-
48
- if (!secretConfig.success) {
49
- await database.close()
50
-
51
- throw new ConfigurationError(
52
- secretConfig.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
53
- )
54
- }
55
-
56
- const hasPrevious =
57
- secretConfig.data.SECRET_ENCRYPTION_KEY_PREVIOUS !== undefined &&
58
- secretConfig.data.SECRET_ENCRYPTION_KEY_PREVIOUS.trim().length > 0
59
-
60
- if (!hasPrevious) {
61
- report('SECRET_ENCRYPTION_KEY_PREVIOUS is not set. Nothing sealed under an older key can be read.')
62
- }
63
-
64
- try {
65
- const outcome = await resealStoredSecrets(database.db, createSecretCipher(secretConfig.data))
66
-
67
- for (const column of outcome.columns) {
68
- report(
69
- `${column.label}: ${String(column.examined)} examined, ${String(column.resealed)} re-sealed, ${String(column.unreadable.length)} unreadable`,
70
- )
71
-
72
- for (const id of column.unreadable) {
73
- reportFatal(` unreadable: ${id}`)
74
- }
75
- }
76
-
77
- if (outcome.unreadable > 0) {
78
- reportFatal(
79
- `\n${String(outcome.unreadable)} value(s) opened under neither key. They were sealed under a key that is not configured, or the rows have been altered. Nothing here can recover them: set SECRET_ENCRYPTION_KEY_PREVIOUS to the key they were sealed with and run this again, or have those records re-created.`,
80
- )
81
-
82
- return 1
83
- }
84
-
85
- report(
86
- outcome.resealed === 0
87
- ? `\nNothing to do: all ${String(outcome.examined)} value(s) are already sealed under the current key.`
88
- : `\nRe-sealed ${String(outcome.resealed)} of ${String(outcome.examined)} value(s). You can now remove SECRET_ENCRYPTION_KEY_PREVIOUS.`,
89
- )
90
-
91
- return 0
92
- } finally {
93
- await database.close()
94
- }
95
- }
96
-
97
24
  try {
98
- process.exit(await reseal())
25
+ const config = resolveKelpieConfig(kelpieConfig, process.env)
26
+ const logger = createLogger({
27
+ level: config.logging.level,
28
+ transports: config.logging.destinations.map(createTransportForDestination),
29
+ })
30
+ process.exit(await runReseal({ config, logger }))
99
31
  } catch (error: unknown) {
100
32
  if (error instanceof ConfigurationError) {
101
33
  reportFatal(error.message)
@@ -7,22 +7,22 @@ import {
7
7
  WebBundleError,
8
8
  connectDatabase,
9
9
  createApp,
10
- createEmailSender,
11
10
  createEventBus,
12
11
  createIdFactory,
13
12
  createLogger,
14
13
  createTransactionScope,
15
- loadConfig,
14
+ createTransportForDestination,
16
15
  readModuleConfigFile,
17
16
  registerModules,
18
17
  resolveActorFrom,
19
18
  resolveClientIpFrom,
19
+ resolveKelpieConfig,
20
20
  runMigrations,
21
21
  serveWebBundle,
22
22
  } from '@kelpie/server'
23
23
  import type { CredentialDependencies } from '@kelpie/server'
24
24
 
25
- import { modules } from '../kelpie.config.ts'
25
+ import kelpieConfig from '../kelpie.config.ts'
26
26
 
27
27
  /**
28
28
  * The entry point. It reads the environment, registers the configured modules,
@@ -41,27 +41,36 @@ function reportFatal(message: string): void {
41
41
  }
42
42
 
43
43
  async function start(): Promise<void> {
44
- const config = loadConfig(process.env)
45
- const logger = createLogger(config.logLevel)
44
+ const config = resolveKelpieConfig(kelpieConfig, process.env)
45
+ const logger = createLogger({
46
+ level: config.logging.level,
47
+ transports: config.logging.destinations.map(createTransportForDestination),
48
+ })
46
49
  const database = connectDatabase(config.databaseUrl, logger)
47
50
  const events = createEventBus(logger)
48
51
  const createId = createIdFactory()
49
52
  const credentials: CredentialDependencies = { db: database.db, now: () => new Date() }
50
53
  const moduleConfig = readModuleConfigFile(config.moduleConfigPath)
51
54
  const contributions = await registerModules({
52
- modules,
53
- environment: process.env,
55
+ modules: kelpieConfig.modules,
56
+ environment: config.env,
54
57
  logger,
55
58
  events,
56
59
  moduleConfig,
57
60
  resolveActor: (context) => resolveActorFrom(credentials, context),
58
61
  services: {
59
62
  db: database.db,
60
- transaction: createTransactionScope({ db: database.db, bus: events, logger }),
61
- email: createEmailSender(config.email, logger),
63
+ transaction: createTransactionScope({ db: database.db, bus: events, logger, createId }),
62
64
  createId,
63
65
  now: () => new Date(),
66
+ appBaseUrl: config.appBaseUrl,
67
+ secretEncryption: config.secretEncryption,
64
68
  },
69
+ // `provider` picks a named sender from the runtime's registry. `'log'` is
70
+ // built in; `'smtp'` is registered by the built-in `smtp-email` core
71
+ // module; other names come from third-party provider modules listed in
72
+ // `kelpie.config.ts`. `from` is the address on every outgoing message.
73
+ email: { provider: config.email.EMAIL_PROVIDER, from: config.email.EMAIL_FROM },
65
74
  })
66
75
 
67
76
  if (process.argv.includes('--no-migrate')) {