nerdsystem 0.0.17 → 0.0.19
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/dist/commands/deploy.js +3 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/platform-scan.d.ts +18 -0
- package/dist/commands/platform-scan.js +58 -0
- package/dist/gates/tools.js +4 -0
- package/package.json +1 -1
- package/rules/semgrep.yml +3 -2
- package/template/.claude/skills/nerdsystem/SKILL.md +1 -1
- package/template/.cursor/rules/nerdsystem.mdc +2 -2
- package/template/.github/copilot-instructions.md +2 -2
- package/template/AGENTS.md +2 -2
- package/template/GEMINI.md +2 -2
package/dist/commands/deploy.js
CHANGED
|
@@ -8,6 +8,7 @@ import { CliError, log, printJson, runOrThrow, table, dashboardUrl, ensureDeps }
|
|
|
8
8
|
import { runChecks } from "./check.js";
|
|
9
9
|
import { appendLineage, cliVersion, LOCAL_ONLY_NOTE, packProject, readLineage, uploadSnapshot, uploadStopMessage } from "./source.js";
|
|
10
10
|
import { lockedGetPaths } from "../gates/tests.js";
|
|
11
|
+
import { platformScan } from "./platform-scan.js";
|
|
11
12
|
import { BUILD_GATES, gateSummary, knownSecretsFrom, probeDeployment, runGates, skippedProbe, SOURCE_GATES, waitForDeployment } from "../gates/index.js";
|
|
12
13
|
const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
|
|
13
14
|
function walk(dir, base = dir) {
|
|
@@ -155,6 +156,8 @@ export async function shipBuild(args) {
|
|
|
155
156
|
appendLineage(root, source.id);
|
|
156
157
|
// "Stored", not "saved": the one line that says "saved" is the final one, which also says the work after it is only on this computer (invariant 7).
|
|
157
158
|
log.ok(`Stored ${pack.fileCount} files (${formatMb(pack.bytes)}) as ${source.id}`);
|
|
159
|
+
// The scanners this computer lacks run on the platform against the copy just stored, before migrations or upload.
|
|
160
|
+
args.results = await platformScan(api, `${projectPath(nerd)}/sources/${source.id}`, args.results);
|
|
158
161
|
}
|
|
159
162
|
log.step("Applying migrations to the production database");
|
|
160
163
|
const mig = await applyRemoteMigrations(root, api);
|
package/dist/commands/init.js
CHANGED
|
@@ -192,4 +192,4 @@ export async function init(dir, args) {
|
|
|
192
192
|
log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
|
|
193
193
|
}
|
|
194
194
|
// Kept in sync with packages/sdk/package.json by the release script.
|
|
195
|
-
const SDK_VERSION = "0.0.
|
|
195
|
+
const SDK_VERSION = "0.0.19";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ApiClient } from "../api.js";
|
|
2
|
+
import type { GateResult } from "../gates/index.js";
|
|
3
|
+
export interface PlatformScanOptions {
|
|
4
|
+
/** How long the deploy waits for the answer. */
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
pollMs?: number;
|
|
7
|
+
sleep?: (ms: number) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* When gitleaks or semgrep did not run here because they are not installed, has the platform run them
|
|
11
|
+
* on the copy of the code that was just stored, and waits for the answer before anything goes live.
|
|
12
|
+
* Returns the gate results with the platform's answers in place of the skipped ones.
|
|
13
|
+
*
|
|
14
|
+
* A blocking finding stops the deploy, exactly as it would have locally. Anything else that goes wrong
|
|
15
|
+
* (the scanners are busy or down, the hourly allowance is used, the answer is late) does not: the two
|
|
16
|
+
* results stay "skip" with the reason, because a platform hiccup must not be what keeps an app offline.
|
|
17
|
+
*/
|
|
18
|
+
export declare function platformScan(api: ApiClient, sourcePath: string, results: GateResult[], opts?: PlatformScanOptions): Promise<GateResult[]>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CliError, log } from "../util.js";
|
|
2
|
+
/** The two scanners most computers do not have. The platform's builder has both. */
|
|
3
|
+
const SCANNERS = ["gitleaks", "semgrep"];
|
|
4
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
/**
|
|
6
|
+
* When gitleaks or semgrep did not run here because they are not installed, has the platform run them
|
|
7
|
+
* on the copy of the code that was just stored, and waits for the answer before anything goes live.
|
|
8
|
+
* Returns the gate results with the platform's answers in place of the skipped ones.
|
|
9
|
+
*
|
|
10
|
+
* A blocking finding stops the deploy, exactly as it would have locally. Anything else that goes wrong
|
|
11
|
+
* (the scanners are busy or down, the hourly allowance is used, the answer is late) does not: the two
|
|
12
|
+
* results stay "skip" with the reason, because a platform hiccup must not be what keeps an app offline.
|
|
13
|
+
*/
|
|
14
|
+
export async function platformScan(api, sourcePath, results, opts = {}) {
|
|
15
|
+
const missing = results.filter((r) => SCANNERS.includes(r.name) && r.status === "skip");
|
|
16
|
+
if (!missing.length)
|
|
17
|
+
return results;
|
|
18
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
19
|
+
const pollMs = opts.pollMs ?? 2500;
|
|
20
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 150_000);
|
|
21
|
+
const skipped = (reason) => results.map((r) => (missing.includes(r) ? { ...r, summary: `${r.name} is not installed here, and the platform's scan ${reason}` } : r));
|
|
22
|
+
log.step(`Scanning the stored code on the platform (${missing.map((m) => m.name).join(" and ")} are not installed here)`);
|
|
23
|
+
let scan;
|
|
24
|
+
try {
|
|
25
|
+
scan = (await api.post(`${sourcePath}/scan`)).scan;
|
|
26
|
+
while ((scan.status === "queued" || scan.status === "running") && Date.now() < deadline) {
|
|
27
|
+
await sleep(pollMs);
|
|
28
|
+
const next = (await api.get(`${sourcePath}/scan`)).scan;
|
|
29
|
+
if (next)
|
|
30
|
+
scan = next;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
const why = err instanceof CliError && err.status === 429 ? "was not run: this organization has used its scans for the hour" : "could not be started";
|
|
35
|
+
log.warn(`The platform's scan ${why}. Continuing with the checks that ran here.`);
|
|
36
|
+
return skipped(why);
|
|
37
|
+
}
|
|
38
|
+
if (scan.status !== "done" || !scan.results) {
|
|
39
|
+
const why = scan.status === "failed" ? "failed" : "did not answer in time";
|
|
40
|
+
log.warn(`The platform's scan ${why}. Continuing with the checks that ran here.`);
|
|
41
|
+
return skipped(why);
|
|
42
|
+
}
|
|
43
|
+
const answered = new Map(scan.results.filter((r) => SCANNERS.includes(r.name)).map((r) => [r.name, r]));
|
|
44
|
+
const merged = results.map((r) => (missing.includes(r) && answered.has(r.name) ? { ...answered.get(r.name), summary: `${answered.get(r.name).summary} (scanned on the platform)` } : r));
|
|
45
|
+
const ran = merged.filter((r) => missing.some((m) => m.name === r.name));
|
|
46
|
+
for (const r of ran) {
|
|
47
|
+
log.info(` ${r.status === "pass" ? "✓" : r.status === "fail" ? "✗" : r.status === "warn" ? "!" : "–"} ${r.title}: ${r.summary}`);
|
|
48
|
+
for (const f of r.findings.slice(0, 20)) {
|
|
49
|
+
log.info(` ${f.severity === "block" ? "✗" : "!"} ${f.rule_id}${f.file ? ` ${f.file}${f.line ? `:${f.line}` : ""}` : ""}${f.masked ? ` (${f.masked})` : ""}`);
|
|
50
|
+
log.info(` ${f.risk}`);
|
|
51
|
+
log.info(` Fix: ${f.fix}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const blocked = ran.filter((r) => r.status === "fail");
|
|
55
|
+
if (blocked.length)
|
|
56
|
+
throw new CliError(`Blocked by: ${blocked.map((g) => g.title).join(", ")}. Nothing went live. Fix the findings above, then deploy again.`, 2, { code: "gate_failed" });
|
|
57
|
+
return merged;
|
|
58
|
+
}
|
package/dist/gates/tools.js
CHANGED
|
@@ -53,6 +53,10 @@ export const semgrepGate = {
|
|
|
53
53
|
const findings = [];
|
|
54
54
|
try {
|
|
55
55
|
const json = JSON.parse(r.stdout);
|
|
56
|
+
// Semgrep exits 0 and reports nothing when its rules file does not load, which reads exactly like a clean
|
|
57
|
+
// scan. That hid a broken rules file from 2026-11-02 to 2026-11-10: a run that could not use the rules is not a pass.
|
|
58
|
+
if ((json.errors ?? []).some((e) => e.level === "error" && /SemgrepError|InvalidRule|config/i.test(e.type ?? "")))
|
|
59
|
+
return result(this, "warn", "semgrep could not load its rules", [], t, "semgrep");
|
|
56
60
|
for (const x of json.results.slice(0, 100)) {
|
|
57
61
|
const short = x.check_id.split(".").pop() ?? x.check_id;
|
|
58
62
|
const rule_id = `semgrep/${short}`;
|
package/package.json
CHANGED
package/rules/semgrep.yml
CHANGED
|
@@ -22,8 +22,9 @@ rules:
|
|
|
22
22
|
severity: WARNING
|
|
23
23
|
message: dangerouslySetInnerHTML with a non-literal value can inject scripts. Sanitize or avoid.
|
|
24
24
|
patterns:
|
|
25
|
-
|
|
26
|
-
- pattern
|
|
25
|
+
# Quoted: an unquoted "__html: $X" reads as a YAML mapping and makes the whole file invalid.
|
|
26
|
+
- pattern: '<$EL dangerouslySetInnerHTML={{ __html: $X }} />'
|
|
27
|
+
- pattern-not: '<$EL dangerouslySetInnerHTML={{ __html: "..." }} />'
|
|
27
28
|
- id: nerd-innerhtml-assignment
|
|
28
29
|
languages: [typescript, javascript]
|
|
29
30
|
severity: WARNING
|
|
@@ -168,7 +168,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
168
168
|
|
|
169
169
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
170
170
|
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
171
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
171
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html?, replyTo? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price. Pass `replyTo` (the owner's address, or one they give you) whenever a person might answer the message; without it replies go to a mailbox nobody reads. Mail goes out under the app's name from the platform's address; on Pro and Studio the owner can switch it to their own domain in the dashboard under Settings, which needs no code change.
|
|
172
172
|
|
|
173
173
|
## Debugging production
|
|
174
174
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
description: How to build, run, and deploy this app on nerdsystem. Applies to server, database, auth, and deploy work.
|
|
3
3
|
alwaysApply: true
|
|
4
4
|
---
|
|
5
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
5
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
|
|
6
6
|
|
|
7
7
|
# Building on nerdsystem
|
|
8
8
|
|
|
@@ -169,7 +169,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
169
169
|
|
|
170
170
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
171
171
|
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
172
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
172
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html?, replyTo? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price. Pass `replyTo` (the owner's address, or one they give you) whenever a person might answer the message; without it replies go to a mailbox nobody reads. Mail goes out under the app's name from the platform's address; on Pro and Studio the owner can switch it to their own domain in the dashboard under Settings, which needs no code change.
|
|
173
173
|
|
|
174
174
|
## Debugging production
|
|
175
175
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -165,7 +165,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
165
165
|
|
|
166
166
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
167
167
|
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
168
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
168
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html?, replyTo? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price. Pass `replyTo` (the owner's address, or one they give you) whenever a person might answer the message; without it replies go to a mailbox nobody reads. Mail goes out under the app's name from the platform's address; on Pro and Studio the owner can switch it to their own domain in the dashboard under Settings, which needs no code change.
|
|
169
169
|
|
|
170
170
|
## Debugging production
|
|
171
171
|
|
package/template/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -165,7 +165,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
165
165
|
|
|
166
166
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
167
167
|
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
168
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
168
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html?, replyTo? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price. Pass `replyTo` (the owner's address, or one they give you) whenever a person might answer the message; without it replies go to a mailbox nobody reads. Mail goes out under the app's name from the platform's address; on Pro and Studio the owner can switch it to their own domain in the dashboard under Settings, which needs no code change.
|
|
169
169
|
|
|
170
170
|
## Debugging production
|
|
171
171
|
|
package/template/GEMINI.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- generated from .claude/skills/nerdsystem/SKILL.md (
|
|
1
|
+
<!-- generated from .claude/skills/nerdsystem/SKILL.md (f814c3a452ab); edit the skill, then run: nerd agents sync -->
|
|
2
2
|
|
|
3
3
|
# Building on nerdsystem
|
|
4
4
|
|
|
@@ -165,7 +165,7 @@ Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule
|
|
|
165
165
|
|
|
166
166
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
167
167
|
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to add it under the app's Settings tab in the dashboard, or to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
168
|
-
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
168
|
+
- **Email**: `sendEmail(c.env, { to, subject, text, html?, replyTo? })` from `@nerdsystem/sdk` sends through the platform. The relay only sends to addresses that have an account on this app (someone who signed up); any other recipient is refused with a 400, and the sender name is always the app's name. On the Free plan the recipient must also have confirmed their address by finishing a password reset (403 otherwise); paid plans can write to every signed-up user. It is limited per day by plan (50 on Free, 1,000 on Pro at $39 a month, 2,000 on Studio at $99 a month), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price. Pass `replyTo` (the owner's address, or one they give you) whenever a person might answer the message; without it replies go to a mailbox nobody reads. Mail goes out under the app's name from the platform's address; on Pro and Studio the owner can switch it to their own domain in the dashboard under Settings, which needs no code change.
|
|
169
169
|
|
|
170
170
|
## Debugging production
|
|
171
171
|
|