@selvajs/server 1.1.0 → 1.2.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,3 @@
1
+ export { SmtpNotificationProvider } from './smtp-provider.js';
2
+ export { readSmtpConfig, type SmtpConfig, type SmtpEnv } from './smtp-config.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/notifications/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { SmtpNotificationProvider } from './smtp-provider.js';
2
+ export { readSmtpConfig } from './smtp-config.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/notifications/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAiC,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,37 @@
1
+ export interface SmtpConfig {
2
+ host: string;
3
+ port: number;
4
+ secure: boolean;
5
+ auth?: {
6
+ user: string;
7
+ pass: string;
8
+ };
9
+ from: string;
10
+ }
11
+ /** The env vars this provider reads. The app passes `process.env` or its framework equivalent. */
12
+ export interface SmtpEnv {
13
+ SMTP_HOST?: string;
14
+ SMTP_PORT?: string;
15
+ SMTP_USER?: string;
16
+ SMTP_PASS?: string;
17
+ SMTP_FROM?: string;
18
+ SMTP_SECURE?: string;
19
+ [key: string]: string | undefined;
20
+ }
21
+ /**
22
+ * Read SMTP settings from an env bag. Taken as a parameter rather than read
23
+ * from `process.env` directly: under `vite dev` the framework does not mirror
24
+ * `.env` into `process.env`, so the app must hand over its own resolved env
25
+ * (`$env/dynamic/private` in SvelteKit) or overrides silently fall back to
26
+ * defaults.
27
+ *
28
+ * Returns null when mail is not configured — the caller treats that as "no
29
+ * mail", not as an error.
30
+ *
31
+ * Throws only when `SMTP_HOST` is set but `SMTP_FROM` is missing: that
32
+ * combination is a half-finished config, and failing loudly beats silently
33
+ * never sending. Every message needs an envelope sender, and guessing one
34
+ * produces mail that fails SPF at the recipient.
35
+ */
36
+ export declare function readSmtpConfig(env: SmtpEnv): SmtpConfig | null;
37
+ //# sourceMappingURL=smtp-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smtp-config.d.ts","sourceRoot":"","sources":["../../src/notifications/smtp-config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,IAAI,EAAE,MAAM,CAAC;CACb;AAED,kGAAkG;AAClG,MAAM,WAAW,OAAO;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IAKrB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAClC;AAOD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,GAAG,IAAI,CAsB9D"}
@@ -0,0 +1,41 @@
1
+ function parsePort(raw) {
2
+ const n = Number(raw ?? 587);
3
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : 587;
4
+ }
5
+ /**
6
+ * Read SMTP settings from an env bag. Taken as a parameter rather than read
7
+ * from `process.env` directly: under `vite dev` the framework does not mirror
8
+ * `.env` into `process.env`, so the app must hand over its own resolved env
9
+ * (`$env/dynamic/private` in SvelteKit) or overrides silently fall back to
10
+ * defaults.
11
+ *
12
+ * Returns null when mail is not configured — the caller treats that as "no
13
+ * mail", not as an error.
14
+ *
15
+ * Throws only when `SMTP_HOST` is set but `SMTP_FROM` is missing: that
16
+ * combination is a half-finished config, and failing loudly beats silently
17
+ * never sending. Every message needs an envelope sender, and guessing one
18
+ * produces mail that fails SPF at the recipient.
19
+ */
20
+ export function readSmtpConfig(env) {
21
+ const host = env.SMTP_HOST?.trim();
22
+ if (!host)
23
+ return null;
24
+ const from = env.SMTP_FROM?.trim();
25
+ if (!from) {
26
+ throw new Error('SMTP_HOST is set but SMTP_FROM is missing — mail needs a sender address.');
27
+ }
28
+ const port = parsePort(env.SMTP_PORT);
29
+ const user = env.SMTP_USER?.trim();
30
+ const pass = env.SMTP_PASS;
31
+ return {
32
+ host,
33
+ port,
34
+ // Port 465 is implicit TLS; 587 and 25 start plaintext and upgrade via
35
+ // STARTTLS, which nodemailer does on its own when `secure` is false.
36
+ secure: env.SMTP_SECURE ? env.SMTP_SECURE === 'true' : port === 465,
37
+ auth: user && pass ? { user, pass } : undefined,
38
+ from
39
+ };
40
+ }
41
+ //# sourceMappingURL=smtp-config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smtp-config.js","sourceRoot":"","sources":["../../src/notifications/smtp-config.ts"],"names":[],"mappings":"AAuBA,SAAS,SAAS,CAAC,GAAuB;IACzC,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC;IAC7B,OAAO,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;AAC5D,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAAC,GAAY;IAC1C,MAAM,IAAI,GAAG,GAAG,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC;IACnC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,IAAI,GAAG,GAAG,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC;IACnC,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CAAC,0EAA0E,CAAC,CAAC;IAC7F,CAAC;IAED,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACtC,MAAM,IAAI,GAAG,GAAG,CAAC,SAAS,EAAE,IAAI,EAAE,CAAC;IACnC,MAAM,IAAI,GAAG,GAAG,CAAC,SAAS,CAAC;IAE3B,OAAO;QACN,IAAI;QACJ,IAAI;QACJ,uEAAuE;QACvE,qEAAqE;QACrE,MAAM,EAAE,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,WAAW,KAAK,MAAM,CAAC,CAAC,CAAC,IAAI,KAAK,GAAG;QACnE,IAAI,EAAE,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS;QAC/C,IAAI;KACJ,CAAC;AACH,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { type ILogger, type INotificationProvider, type OutboundMessage, type SendResult } from '@selvajs/platform';
2
+ import { type SmtpEnv } from './smtp-config.js';
3
+ /**
4
+ * SMTP transport. Any server works — a transactional provider, an internal
5
+ * relay, or a mailbox with an app password.
6
+ *
7
+ * Settings are read per send rather than cached, so a restart is the only
8
+ * thing needed to reconfigure and a rotated password takes effect without
9
+ * anyone remembering there is a cache.
10
+ */
11
+ export declare class SmtpNotificationProvider implements INotificationProvider {
12
+ private readonly env;
13
+ readonly name = "SMTP";
14
+ constructor(env: SmtpEnv);
15
+ send(message: OutboundMessage, log?: ILogger): Promise<SendResult>;
16
+ isConfigured(): boolean;
17
+ }
18
+ //# sourceMappingURL=smtp-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smtp-provider.d.ts","sourceRoot":"","sources":["../../src/notifications/smtp-provider.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAkB,KAAK,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhE;;;;;;;GAOG;AACH,qBAAa,wBAAyB,YAAW,qBAAqB;IAGzD,OAAO,CAAC,QAAQ,CAAC,GAAG;IAFhC,QAAQ,CAAC,IAAI,UAAU;gBAEM,GAAG,EAAE,OAAO;IAEnC,IAAI,CAAC,OAAO,EAAE,eAAe,EAAE,GAAG,GAAE,OAA0B,GAAG,OAAO,CAAC,UAAU,CAAC;IA6C1F,YAAY,IAAI,OAAO;CAOvB"}
@@ -0,0 +1,71 @@
1
+ import { NoopLogger } from '@selvajs/platform';
2
+ import { readSmtpConfig } from './smtp-config.js';
3
+ /**
4
+ * SMTP transport. Any server works — a transactional provider, an internal
5
+ * relay, or a mailbox with an app password.
6
+ *
7
+ * Settings are read per send rather than cached, so a restart is the only
8
+ * thing needed to reconfigure and a rotated password takes effect without
9
+ * anyone remembering there is a cache.
10
+ */
11
+ export class SmtpNotificationProvider {
12
+ env;
13
+ name = 'SMTP';
14
+ constructor(env) {
15
+ this.env = env;
16
+ }
17
+ async send(message, log = new NoopLogger()) {
18
+ let config;
19
+ try {
20
+ config = readSmtpConfig(this.env);
21
+ }
22
+ catch (err) {
23
+ log.error('SMTP is misconfigured', {
24
+ component: 'SmtpNotificationProvider',
25
+ error: err instanceof Error ? err.message : String(err)
26
+ });
27
+ return { status: 'failed', reason: 'misconfigured' };
28
+ }
29
+ if (!config)
30
+ return { status: 'not-configured' };
31
+ try {
32
+ // Lazy: an instance with no SMTP_HOST never pays to load nodemailer.
33
+ const { createTransport } = await import('nodemailer');
34
+ const transport = createTransport({
35
+ host: config.host,
36
+ port: config.port,
37
+ secure: config.secure,
38
+ auth: config.auth
39
+ });
40
+ await transport.sendMail({
41
+ from: config.from,
42
+ to: message.to,
43
+ subject: message.subject,
44
+ text: message.text,
45
+ html: message.html
46
+ });
47
+ return { status: 'sent' };
48
+ }
49
+ catch (err) {
50
+ const reason = err instanceof Error ? err.message : String(err);
51
+ // Recipient is personal data — logged at warn only, as the minimum
52
+ // needed to diagnose a bounce. Never the subject or body.
53
+ log.warn('Failed to send mail', {
54
+ component: 'SmtpNotificationProvider',
55
+ to: message.to,
56
+ kind: message.kind,
57
+ reason
58
+ });
59
+ return { status: 'failed', reason };
60
+ }
61
+ }
62
+ isConfigured() {
63
+ try {
64
+ return readSmtpConfig(this.env) !== null;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ }
71
+ //# sourceMappingURL=smtp-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smtp-provider.js","sourceRoot":"","sources":["../../src/notifications/smtp-provider.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,UAAU,EAKV,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,cAAc,EAAgB,MAAM,kBAAkB,CAAC;AAEhE;;;;;;;GAOG;AACH,MAAM,OAAO,wBAAwB;IAGP;IAFpB,IAAI,GAAG,MAAM,CAAC;IAEvB,YAA6B,GAAY;QAAZ,QAAG,GAAH,GAAG,CAAS;IAAG,CAAC;IAE7C,KAAK,CAAC,IAAI,CAAC,OAAwB,EAAE,MAAe,IAAI,UAAU,EAAE;QACnE,IAAI,MAAM,CAAC;QACX,IAAI,CAAC;YACJ,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,GAAG,CAAC,KAAK,CAAC,uBAAuB,EAAE;gBAClC,SAAS,EAAE,0BAA0B;gBACrC,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;aACvD,CAAC,CAAC;YACH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC;QACtD,CAAC;QACD,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;QAEjD,IAAI,CAAC;YACJ,qEAAqE;YACrE,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,MAAM,CAAC,YAAY,CAAC,CAAC;YACvD,MAAM,SAAS,GAAG,eAAe,CAAC;gBACjC,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,IAAI,EAAE,MAAM,CAAC,IAAI;aACjB,CAAC,CAAC;YAEH,MAAM,SAAS,CAAC,QAAQ,CAAC;gBACxB,IAAI,EAAE,MAAM,CAAC,IAAI;gBACjB,EAAE,EAAE,OAAO,CAAC,EAAE;gBACd,OAAO,EAAE,OAAO,CAAC,OAAO;gBACxB,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,IAAI,EAAE,OAAO,CAAC,IAAI;aAClB,CAAC,CAAC;YACH,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;QAC3B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACd,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YAChE,mEAAmE;YACnE,0DAA0D;YAC1D,GAAG,CAAC,IAAI,CAAC,qBAAqB,EAAE;gBAC/B,SAAS,EAAE,0BAA0B;gBACrC,EAAE,EAAE,OAAO,CAAC,EAAE;gBACd,IAAI,EAAE,OAAO,CAAC,IAAI;gBAClB,MAAM;aACN,CAAC,CAAC;YACH,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;QACrC,CAAC;IACF,CAAC;IAED,YAAY;QACX,IAAI,CAAC;YACJ,OAAO,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,IAAI,CAAC;QAC1C,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,KAAK,CAAC;QACd,CAAC;IACF,CAAC;CACD"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Reverse-proxy and body-limit configuration rules, evaluated against a plain
3
+ * env bag. Pure predicates — no filesystem, no network, no process.
4
+ *
5
+ * These settings are read by adapter-node, not by Selva, so getting them wrong
6
+ * is invisible: nothing throws, nothing logs, and the deployment serves traffic
7
+ * while a whole class of requests fails. `selva doctor` reports them on the
8
+ * host; the admin health panel runs the same rules so an operator who never
9
+ * opens a shell still sees them.
10
+ *
11
+ * `packages/cli/src/checks/config.js` carries a duplicate of every rule here.
12
+ * Duplicated because the CLI is dependency-free by design — it scaffolds the
13
+ * deployment that installs this runtime, so it cannot import from it. A shared
14
+ * fixture table (`ops/__tests__/deployment-config-fixtures.js`) is asserted by
15
+ * both test suites, so a rule changed on one side fails CI on the other.
16
+ */
17
+ /** Neutral severity. Callers map it onto their own presentation. */
18
+ export type ConfigVerdict = 'ok' | 'warn' | 'fail';
19
+ export interface ConfigFinding {
20
+ id: string;
21
+ label: string;
22
+ verdict: ConfigVerdict;
23
+ summary: string;
24
+ /** Concrete next step. Absent when the verdict is `ok`. */
25
+ remediation?: string;
26
+ }
27
+ /** Just the vars these rules read. Values are raw strings, as they come from `.env`. */
28
+ export interface DeploymentEnv {
29
+ ORIGIN?: string;
30
+ ADDRESS_HEADER?: string;
31
+ XFF_DEPTH?: string;
32
+ BODY_SIZE_LIMIT?: string;
33
+ COMPUTE_REQUEST_MAX_BYTES?: string;
34
+ [key: string]: string | undefined;
35
+ }
36
+ /**
37
+ * Parse a `BODY_SIZE_LIMIT` the way adapter-node does, so this agrees with the
38
+ * thing that actually rejects the request.
39
+ *
40
+ * The quirk is load-bearing: adapter-node reads only the LAST character as the
41
+ * suffix, so `60mb` parses as the digits `60m` and happens to mean 60 MB, while
42
+ * `60xy` silently becomes NaN. Returns bytes, or null when the value is one
43
+ * adapter-node can't use.
44
+ */
45
+ export declare function parseBodySizeLimit(raw: string | undefined | null): number | null;
46
+ /**
47
+ * BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
48
+ * backstop rejects the upload first — with a non-JSON body the app never sees
49
+ * and cannot log. Unset is the dangerous case, not a neutral one: adapter-node
50
+ * falls back to 512 KB, so every upload 413s on a deployment that looks fine.
51
+ */
52
+ export declare function checkBodySizeLimit(env: DeploymentEnv): ConfigFinding;
53
+ /**
54
+ * `ADDRESS_HEADER` / `XFF_DEPTH` are read by adapter-node, not by Selva, and
55
+ * unset they are invisible: nothing fails, nothing logs, and the deployment
56
+ * looks healthy.
57
+ *
58
+ * What actually happens is that `getClientAddress()` returns the socket peer,
59
+ * which behind a reverse proxy is the proxy — `127.0.0.1` for every request
60
+ * from every user. Login rate limiting keys on that, so the whole instance
61
+ * shares one bucket: five failed logins from anywhere return 429 to everyone,
62
+ * and only a *successful* login clears a bucket, which nobody can then reach.
63
+ *
64
+ * `ORIGIN` is the proxy tell. It is only needed when the app is served under a
65
+ * public URL it can't derive from its own socket, so its presence means a proxy
66
+ * is terminating requests. Without it we say nothing: on a directly-reachable
67
+ * app these settings are a footgun, since `X-Forwarded-For` is client-supplied
68
+ * and anyone could then pick their own rate-limit bucket.
69
+ */
70
+ export declare function checkClientAddress(env: DeploymentEnv): ConfigFinding;
71
+ /** Every deployment-config rule, in report order. */
72
+ export declare function checkDeploymentConfig(env: DeploymentEnv): ConfigFinding[];
73
+ //# sourceMappingURL=deploymentConfig.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deploymentConfig.d.ts","sourceRoot":"","sources":["../../src/ops/deploymentConfig.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG,IAAI,GAAG,MAAM,GAAG,MAAM,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,aAAa,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,wFAAwF;AACxF,MAAM,WAAW,aAAa;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,yBAAyB,CAAC,EAAE,MAAM,CAAC;IAKnC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAClC;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAUhF;AAOD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,aAAa,GAAG,aAAa,CA0CpE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,aAAa,GAAG,aAAa,CAwDpE;AAED,qDAAqD;AACrD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,aAAa,GAAG,aAAa,EAAE,CAEzE"}
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Reverse-proxy and body-limit configuration rules, evaluated against a plain
3
+ * env bag. Pure predicates — no filesystem, no network, no process.
4
+ *
5
+ * These settings are read by adapter-node, not by Selva, so getting them wrong
6
+ * is invisible: nothing throws, nothing logs, and the deployment serves traffic
7
+ * while a whole class of requests fails. `selva doctor` reports them on the
8
+ * host; the admin health panel runs the same rules so an operator who never
9
+ * opens a shell still sees them.
10
+ *
11
+ * `packages/cli/src/checks/config.js` carries a duplicate of every rule here.
12
+ * Duplicated because the CLI is dependency-free by design — it scaffolds the
13
+ * deployment that installs this runtime, so it cannot import from it. A shared
14
+ * fixture table (`ops/__tests__/deployment-config-fixtures.js`) is asserted by
15
+ * both test suites, so a rule changed on one side fails CI on the other.
16
+ */
17
+ /**
18
+ * Parse a `BODY_SIZE_LIMIT` the way adapter-node does, so this agrees with the
19
+ * thing that actually rejects the request.
20
+ *
21
+ * The quirk is load-bearing: adapter-node reads only the LAST character as the
22
+ * suffix, so `60mb` parses as the digits `60m` and happens to mean 60 MB, while
23
+ * `60xy` silently becomes NaN. Returns bytes, or null when the value is one
24
+ * adapter-node can't use.
25
+ */
26
+ export function parseBodySizeLimit(raw) {
27
+ if (raw == null || raw === '')
28
+ return null;
29
+ const value = String(raw).trim();
30
+ if (value === '')
31
+ return null;
32
+ const units = { K: 1024, M: 1024 * 1024, G: 1024 * 1024 * 1024 };
33
+ const multiplier = units[value.at(-1).toUpperCase()] ?? 1;
34
+ const bytes = Number(multiplier !== 1 ? value.slice(0, -1) : value) * multiplier;
35
+ if (!Number.isFinite(bytes) || bytes <= 0)
36
+ return null;
37
+ return bytes;
38
+ }
39
+ /** The app's own default for COMPUTE_REQUEST_MAX_BYTES (compute/limits.ts). */
40
+ const COMPUTE_REQUEST_DEFAULT_BYTES = 256 * 1024 * 1024;
41
+ const asMb = (bytes) => `${Math.round(bytes / (1024 * 1024))} MB`;
42
+ /**
43
+ * BODY_SIZE_LIMIT has to clear the compute request cap, or adapter-node's global
44
+ * backstop rejects the upload first — with a non-JSON body the app never sees
45
+ * and cannot log. Unset is the dangerous case, not a neutral one: adapter-node
46
+ * falls back to 512 KB, so every upload 413s on a deployment that looks fine.
47
+ */
48
+ export function checkBodySizeLimit(env) {
49
+ const base = { id: 'body-size-limit', label: 'Upload size limit' };
50
+ const raw = env.BODY_SIZE_LIMIT;
51
+ const requestCap = Number(env.COMPUTE_REQUEST_MAX_BYTES) || COMPUTE_REQUEST_DEFAULT_BYTES;
52
+ if (raw == null || raw === '') {
53
+ return {
54
+ ...base,
55
+ verdict: 'fail',
56
+ summary: 'BODY_SIZE_LIMIT is unset — adapter-node falls back to 512 KB, so uploads over ' +
57
+ 'that fail with an opaque 413 no app log records.',
58
+ remediation: `Set BODY_SIZE_LIMIT to at least ${asMb(requestCap)} (the compute request cap) and restart.`
59
+ };
60
+ }
61
+ const bytes = parseBodySizeLimit(raw);
62
+ if (bytes === null) {
63
+ return {
64
+ ...base,
65
+ verdict: 'fail',
66
+ summary: `BODY_SIZE_LIMIT="${raw}" — adapter-node cannot parse this and throws on boot.`,
67
+ remediation: 'Use a byte count or a single K/M/G suffix, e.g. "256M".'
68
+ };
69
+ }
70
+ if (bytes < requestCap) {
71
+ return {
72
+ ...base,
73
+ verdict: 'warn',
74
+ summary: `BODY_SIZE_LIMIT=${raw} (${asMb(bytes)}) is below COMPUTE_REQUEST_MAX_BYTES ` +
75
+ `(${asMb(requestCap)}) — the global cap rejects first, making the compute cap dead config.`,
76
+ remediation: `Raise BODY_SIZE_LIMIT to at least ${asMb(requestCap)} and restart.`
77
+ };
78
+ }
79
+ return {
80
+ ...base,
81
+ verdict: 'ok',
82
+ summary: `BODY_SIZE_LIMIT=${raw} clears the compute request cap.`
83
+ };
84
+ }
85
+ /**
86
+ * `ADDRESS_HEADER` / `XFF_DEPTH` are read by adapter-node, not by Selva, and
87
+ * unset they are invisible: nothing fails, nothing logs, and the deployment
88
+ * looks healthy.
89
+ *
90
+ * What actually happens is that `getClientAddress()` returns the socket peer,
91
+ * which behind a reverse proxy is the proxy — `127.0.0.1` for every request
92
+ * from every user. Login rate limiting keys on that, so the whole instance
93
+ * shares one bucket: five failed logins from anywhere return 429 to everyone,
94
+ * and only a *successful* login clears a bucket, which nobody can then reach.
95
+ *
96
+ * `ORIGIN` is the proxy tell. It is only needed when the app is served under a
97
+ * public URL it can't derive from its own socket, so its presence means a proxy
98
+ * is terminating requests. Without it we say nothing: on a directly-reachable
99
+ * app these settings are a footgun, since `X-Forwarded-For` is client-supplied
100
+ * and anyone could then pick their own rate-limit bucket.
101
+ */
102
+ export function checkClientAddress(env) {
103
+ const base = { id: 'client-address', label: 'Client IP behind proxy' };
104
+ const header = env.ADDRESS_HEADER;
105
+ const depth = env.XFF_DEPTH;
106
+ if (!env.ORIGIN) {
107
+ return {
108
+ ...base,
109
+ verdict: 'ok',
110
+ summary: 'ADDRESS_HEADER is not needed — ORIGIN is unset, so no reverse proxy is in play.'
111
+ };
112
+ }
113
+ if (!header) {
114
+ return {
115
+ ...base,
116
+ verdict: 'fail',
117
+ summary: 'ADDRESS_HEADER is unset while ORIGIN is set — every request looks like it came from ' +
118
+ 'the proxy, so all users share ONE login rate-limit bucket. Five failed logins from ' +
119
+ 'anyone locks out the whole instance for 15 minutes.',
120
+ remediation: 'Set ADDRESS_HEADER=X-Forwarded-For and XFF_DEPTH=<number of proxies>, then restart. ' +
121
+ 'To count your proxies, see docs/self-hosting/deployment/reverse-proxy.md.'
122
+ };
123
+ }
124
+ if (header.toLowerCase() === 'x-forwarded-for' && !depth) {
125
+ return {
126
+ ...base,
127
+ verdict: 'warn',
128
+ summary: `ADDRESS_HEADER=${header} but XFF_DEPTH is unset. X-Forwarded-For is a ` +
129
+ 'client-appendable list, so a caller can prepend fake entries — adapter-node needs ' +
130
+ 'the proxy count to know which entry is real.',
131
+ remediation: 'Set XFF_DEPTH=1 for a single reverse proxy, 2 if a CDN or load balancer sits in ' +
132
+ 'front of it. Unsure? docs/self-hosting/deployment/reverse-proxy.md shows how to ' +
133
+ 'read the header the app actually receives.'
134
+ };
135
+ }
136
+ if (depth !== undefined && !/^[1-9][0-9]*$/.test(depth)) {
137
+ return {
138
+ ...base,
139
+ verdict: 'fail',
140
+ summary: `XFF_DEPTH="${depth}" is not a positive integer.`,
141
+ remediation: 'It counts proxies from the outside in — use 1 for a single reverse proxy.'
142
+ };
143
+ }
144
+ return {
145
+ ...base,
146
+ verdict: 'ok',
147
+ summary: `ADDRESS_HEADER=${header}${depth ? ` (XFF_DEPTH=${depth})` : ''}.`
148
+ };
149
+ }
150
+ /** Every deployment-config rule, in report order. */
151
+ export function checkDeploymentConfig(env) {
152
+ return [checkClientAddress(env), checkBodySizeLimit(env)];
153
+ }
154
+ //# sourceMappingURL=deploymentConfig.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deploymentConfig.js","sourceRoot":"","sources":["../../src/ops/deploymentConfig.ts"],"names":[],"mappings":"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"}
@@ -1,2 +1,3 @@
1
1
  export { parseSemver, isNewer, compareCore, satisfiesRange, type ReleaseChannel } from './semver.js';
2
+ export { checkClientAddress, checkBodySizeLimit, checkDeploymentConfig, parseBodySizeLimit, type ConfigFinding, type ConfigVerdict, type DeploymentEnv } from './deploymentConfig.js';
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EACd,KAAK,cAAc,EACnB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAGA,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EACd,KAAK,cAAc,EACnB,MAAM,aAAa,CAAC;AAErB,OAAO,EACN,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,MAAM,uBAAuB,CAAC"}
package/dist/ops/index.js CHANGED
@@ -1,4 +1,5 @@
1
- // Deployment/ops helpers — channel-aware semver comparison and engines.node
2
- // range checks.
1
+ // Deployment/ops helpers — channel-aware semver comparison, engines.node range
2
+ // checks, and the reverse-proxy/body-limit configuration rules.
3
3
  export { parseSemver, isNewer, compareCore, satisfiesRange } from './semver.js';
4
+ export { checkClientAddress, checkBodySizeLimit, checkDeploymentConfig, parseBodySizeLimit } from './deploymentConfig.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,gBAAgB;AAEhB,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EAEd,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ops/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,gEAAgE;AAEhE,OAAO,EACN,WAAW,EACX,OAAO,EACP,WAAW,EACX,cAAc,EAEd,MAAM,aAAa,CAAC;AAErB,OAAO,EACN,kBAAkB,EAClB,kBAAkB,EAClB,qBAAqB,EACrB,kBAAkB,EAIlB,MAAM,uBAAuB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/server",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Transport-agnostic server building blocks for apps built on the Selva engine (limits, rate limiting, SSRF guard, remote-definition fetch, definition service + render loader); the solve core lives in @selvajs/solve/server",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -70,6 +70,11 @@
70
70
  "selva-source": "./src/ops/index.ts",
71
71
  "types": "./dist/ops/index.d.ts",
72
72
  "import": "./dist/ops/index.js"
73
+ },
74
+ "./notifications": {
75
+ "selva-source": "./src/notifications/index.ts",
76
+ "types": "./dist/notifications/index.d.ts",
77
+ "import": "./dist/notifications/index.js"
73
78
  }
74
79
  },
75
80
  "files": [
@@ -79,24 +84,30 @@
79
84
  "!**/*.spec.*"
80
85
  ],
81
86
  "dependencies": {
82
- "@selvajs/platform": "^0.19.1",
83
87
  "@selvajs/compute": "^4.0.2",
88
+ "@selvajs/platform": "^0.20.0",
84
89
  "@selvajs/schemas": "^5.0.1"
85
90
  },
86
91
  "peerDependencies": {
87
92
  "@sentry/node": ">=8",
93
+ "nodemailer": ">=7",
88
94
  "pino": ">=9"
89
95
  },
90
96
  "peerDependenciesMeta": {
91
97
  "@sentry/node": {
92
98
  "optional": true
93
99
  },
100
+ "nodemailer": {
101
+ "optional": true
102
+ },
94
103
  "pino": {
95
104
  "optional": true
96
105
  }
97
106
  },
98
107
  "devDependencies": {
99
108
  "@types/node": "^26.2.0",
109
+ "@types/nodemailer": "^8.0.1",
110
+ "nodemailer": "^9.0.5",
100
111
  "typescript": "~6.0.3",
101
112
  "vitest": "^4.1.10",
102
113
  "@selvajs/config": "0.0.4"