@kurtbruns/kestrel 1.17.0 → 1.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/.dev.vars.example +10 -0
- package/CHANGELOG.md +30 -1
- package/bin/kestrel.mjs +1 -1
- package/cli/dev.mjs +3 -3
- package/cli/init.mjs +98 -4
- package/dist/public/dashboard/app.js +288 -286
- package/dist/public/dashboard/app.js.map +4 -4
- package/dist/public/dashboard/index.html +6 -3
- package/dist/public/dashboard/styles.css +10 -0
- package/dist/worker/index.js +5606 -5095
- package/docs/README.md +5 -3
- package/docs/get-started/02-deploy.md +24 -20
- package/docs/get-started/03-access.md +9 -5
- package/docs/get-started/04-aws-account.md +68 -0
- package/docs/get-started/{04-ses.md → 05-ses.md} +89 -92
- package/docs/get-started/06-production-access.md +64 -0
- package/docs/get-started/{05-verify.md → 07-verify.md} +16 -26
- package/docs/get-started/{06-go-live.md → 08-go-live.md} +2 -2
- package/docs/guides/01-allow-claude.md +1 -1
- package/docs/guides/02-automate.md +1 -1
- package/docs/guides/03-notifications.md +3 -3
- package/docs/guides/07-upgrade.md +4 -4
- package/docs/guides/09-as-your-list-grows.md +8 -18
- package/docs/publishing/02-sending-rules.md +2 -2
- package/docs/publishing/03-name-your-publication.md +2 -0
- package/docs/publishing/04-write-a-post.md +1 -1
- package/docs/publishing/06-troubleshooting.md +11 -7
- package/docs/publishing/09-share-your-subscribe-link.md +1 -1
- package/docs/publishing/10-check-your-setup.md +4 -2
- package/docs/reference/01-configuration.md +14 -12
- package/docs/reference/02-sending-domain-dns.md +4 -4
- package/migrations/0015_provider_account.sql +18 -0
- package/package.json +1 -1
- package/template/claude/CLAUDE.md +3 -3
- package/template/wrangler.jsonc +1 -1
package/.dev.vars.example
CHANGED
|
@@ -41,3 +41,13 @@ MIN_LEAD_SECONDS="60"
|
|
|
41
41
|
# For one run, set it in the shell instead: SIMULATE_SENDS=ses npm run dev
|
|
42
42
|
# Only honored in a dev-shaped env (fake transport, no Access, APP_ORIGIN on localhost).
|
|
43
43
|
SIMULATE_SENDS="ses"
|
|
44
|
+
|
|
45
|
+
# --- SES account simulation (local only) ---
|
|
46
|
+
# Stands in for the SES account the app reads, so Settings and the dashboard show a state a
|
|
47
|
+
# real account would: sandbox, pending (AWS reviewing a request for production access),
|
|
48
|
+
# denied, production, or permission (the AWS key isn't allowed to read the account). Add
|
|
49
|
+
# ":unrouted" (e.g. "sandbox:unrouted") for bounces that reach no configuration set, which
|
|
50
|
+
# shows the bounces warning. It changes what the editor shows, not how a simulated send goes
|
|
51
|
+
# out. Needs SIMULATE_SENDS=ses; "off" leaves the account unread.
|
|
52
|
+
# For one run, set it in the shell instead: SIMULATE_ACCOUNT=sandbox npm run dev
|
|
53
|
+
SIMULATE_ACCOUNT="off"
|
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,34 @@ To move a running instance from one version to another, follow [Upgrade to a new
|
|
|
10
10
|
|
|
11
11
|
<!-- Add entries under Added / Changed / Fixed / Breaking. One operator-facing line each; see .claude/rules/changelog.md. Upgrade steps (a migration, a new setting) go once, in the Upgrading paragraph, never on an entry. -->
|
|
12
12
|
|
|
13
|
+
## [1.18.0] - 2026-10-26
|
|
14
|
+
|
|
15
|
+
**Upgrading from 1.17.0.** In your instance's repository, run `npm run sync-migrations`, which brings in one database migration, `0015_provider_account`: a new table where the app keeps what it reads of your SES account, which rewrites nothing. Apply it before you deploy, with `npx wrangler d1 migrations apply DB --remote --env production` (and `--local` for local development). If your instance already sends through Amazon SES, let the app's AWS key read the account before you deploy: in the IAM console, open **Policies**, then `kestrel-send-email`, choose **Edit**, add `"ses:GetAccount"` and `"ses:GetEmailIdentity"` to its `Action` list in the JSON editor, and save. Otherwise the app's first check finds the key not allowed, Settings says so with **Add the permission**, and the app asks again only hourly. An instance not yet connected to SES skips this, since Connect Amazon SES's policy now includes both. Then deploy with `npx wrangler deploy --env production`. `SES_MAX_SEND_RATE` is now a cap: a send keeps to the lower of it and your account's own rate, so the old guide's `"1"` would hold every send at one a second even after AWS grants production access. Once Settings → Email sender shows a **Sandbox** or **Production** badge beside Amazon SES, the app has read your account: remove `SES_MAX_SEND_RATE` from the `production` block's `vars` in `wrangler.jsonc`, or raise it to the most you want, and deploy again. Run `npm run check-context`: this release changes the operator's Claude context, and a copy you've edited is merged by hand. An existing instance keeps its Worker, database, and bucket names: don't rename them, and don't copy a new instance's names from the template into your `wrangler.jsonc`. If you're partway through the setup guide, continue from its renumbered pages: 1.17.0's Connect Amazon SES section 4 is now its own page, Request production access, after Connect Amazon SES.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- On Amazon SES, the app reads the account itself, about every ten minutes while nothing is sending (SPEC §9). Settings → Email sender shows whether SES is still in the sandbox, where AWS's review of a production-access request stands, and the send rate and where it comes from, and says when the app's AWS key isn't allowed to read the account; the dashboard's setup steps add Request production access, checked off once AWS grants it; and a warning says when no configuration set applies, so bounces and complaints would never reach the app. `GET /api/provider` carries all of it for Claude, and an AWS key that can't read the account reads as unknown, never as an error.
|
|
20
|
+
- `kestrel init` takes `--name` to name a new instance, which otherwise takes its folder's name: lowercase letters, digits, and hyphens, at most 46 characters. It names the instance's Worker, D1 database, and R2 bucket after it instead of `kestrel`: `NAME-production` for the Worker and the database, `NAME-media-production` for the bucket, and `NAME-dev` for the development Worker a deploy without `--env production` would create. So a second instance in the same Cloudflare account no longer deploys over the first. Deploy the app now recommends naming the repository after the publication. An existing instance keeps its names, and nothing changes for it.
|
|
21
|
+
- Local development can stand in for the SES account with `SIMULATE_ACCOUNT` (`sandbox`, `pending`, `denied`, `production`, or `permission`, optionally `:unrouted`, beside `SIMULATE_SENDS=ses`), so Settings and the dashboard can be seen in each state without an AWS account. `kestrel dev` takes it from the shell for one run.
|
|
22
|
+
- Get started has a new step, Create an AWS account, before Connect Amazon SES. It covers the sign-up path, plan, and support choices, securing the root user, a budget alert, and what running several publications through one account shares. Deploy the app's Before you begin says to start it early.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Until a post is listed, the home page is the subscribe form itself, with its line saying each signup is confirmed by email. A first visitor, or AWS reviewing the site for SES production access, sees how joining works without a click.
|
|
27
|
+
- Before any post is listed and while no email provider is connected, the home page reads "No posts yet. Subscribe to get the first one.", as two sentences without a dash.
|
|
28
|
+
- Name your publication now suggests a tagline that says what the newsletter covers and how often it goes out.
|
|
29
|
+
- A send keeps to your SES account's own maximum send rate once the app has read it, or to `SES_MAX_SEND_RATE` where that is lower, so a raise from AWS needs no deploy (SPEC §12). The setup guide no longer sets the rate to 1 in the sandbox and changes it after the grant, and its IAM policy adds `ses:GetAccount` and `ses:GetEmailIdentity`.
|
|
30
|
+
- The setup guide asks AWS for SES production access on a page of its own, Request production access, after Connect Amazon SES, so AWS's reviewer finds a live site with SES connected and bounces handled. It ends by checking the grant, in Settings or from `GET /api/provider`, and Go live opens by pointing there. Verify it works now runs wholly in the sandbox, while AWS reviews the request.
|
|
31
|
+
- Connect Amazon SES sets the custom MAIL FROM domain on the Create identity form, so all five DNS records go to Cloudflare in one trip, with a zone file to import. It verifies your two test addresses in its own section, and checks each piece in the section that sets it up.
|
|
32
|
+
- The setup guide's pages from Connect Amazon SES on, and the sections of Connect Amazon SES and Verify it works, are renumbered. A bookmark to a section now lands at its page's top, and a GitHub link to a renamed page, such as `docs/get-started/04-ses.md` or `05-verify.md`, finds no page. The editor's Docs links keep working.
|
|
33
|
+
- The operator's Claude context reads whether SES is in the sandbox from `GET /api/provider` before helping share the subscribe link, instead of asking the publisher, and names the two permissions to add when the app's AWS key can't read the account. For production access it points to Request production access, instead of a Go live section that doesn't exist. The connector's instructions read the same state. Merge it into an edited copy by hand.
|
|
34
|
+
- The operator's Claude context names the Worker a deploy without `--env production` creates by the top-level `name` in `wrangler.jsonc`, not as `kestrel`, since a new instance's is its own name. Merge it into an edited copy by hand.
|
|
35
|
+
- The operator's Claude context, following a setup guide page, runs the check that ends each section, and the page's **Check it** list where it has one, since Connect Amazon SES and Request production access have none. Merge it into an edited copy by hand.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- A locked editor (before Access is set up, or after a sign-in Kestrel refuses) no longer flashes the editor's sidebar before its explanation: the dashboard shows the publication's chrome only once it knows who is signing in.
|
|
40
|
+
|
|
13
41
|
## [1.17.0] - 2026-10-26
|
|
14
42
|
|
|
15
43
|
### Changed
|
|
@@ -661,7 +689,8 @@ The first tagged release: a self-contained newsletter app on a Cloudflare Worker
|
|
|
661
689
|
- A build-version stamp: every instance reports its version, commit, and build time, read-only in the editor and at `GET /api/version`.
|
|
662
690
|
- This changelog and a semantic-versioning release process.
|
|
663
691
|
|
|
664
|
-
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.
|
|
692
|
+
[Unreleased]: https://github.com/kurtbruns/kestrel/compare/v1.18.0...HEAD
|
|
693
|
+
[1.18.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.18.0
|
|
665
694
|
[1.17.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.17.0
|
|
666
695
|
[1.16.0]: https://github.com/kurtbruns/kestrel/releases/tag/v1.16.0
|
|
667
696
|
[1.15.1]: https://github.com/kurtbruns/kestrel/releases/tag/v1.15.1
|
package/bin/kestrel.mjs
CHANGED
|
@@ -26,7 +26,7 @@ const HELP = `kestrel ${PACKAGE.version}
|
|
|
26
26
|
|
|
27
27
|
Usage: kestrel <command> [options]
|
|
28
28
|
|
|
29
|
-
init [dir]
|
|
29
|
+
init [dir] [--name <name>] write a new instance repository here (or in dir)
|
|
30
30
|
dev [wrangler dev args] run locally, with the send sweep once a minute (PORT picks the port)
|
|
31
31
|
sync-migrations copy this release's migrations into migrations/
|
|
32
32
|
check-context [--update] compare the Claude files in .claude/ with this release's
|
package/cli/dev.mjs
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* D1 route would 500 until the migrations ran. The local database is probed and, when its
|
|
23
23
|
* tables are missing, migrated before the server starts. Never for `--remote`.
|
|
24
24
|
*
|
|
25
|
-
* Per-run settings. `SIMULATE_SENDS`, `MIN_LEAD_SECONDS`, and
|
|
26
|
-
* shell are forwarded over `.dev.vars`, so `SIMULATE_SENDS=ses kestrel dev` picks a
|
|
25
|
+
* Per-run settings. `SIMULATE_SENDS`, `SIMULATE_ACCOUNT`, `MIN_LEAD_SECONDS`, and
|
|
26
|
+
* `SUBREQUEST_BUDGET` set in the shell are forwarded over `.dev.vars`, so `SIMULATE_SENDS=ses kestrel dev` picks a
|
|
27
27
|
* simulation profile for one run without editing a file. A `.dev.vars` missing a setting that
|
|
28
28
|
* `.dev.vars.example` gives a value is named, rather than left to differ quietly. What
|
|
29
29
|
* `SIMULATE_SENDS` makes of this run (a simulated provider, or not connected) is said in one
|
|
@@ -49,7 +49,7 @@ import { captureWrangler, runWrangler, spawnWrangler } from "./wrangler.mjs";
|
|
|
49
49
|
const PREFERRED_PORT = 8787;
|
|
50
50
|
|
|
51
51
|
// The dev settings a shell may set for one run (see the header).
|
|
52
|
-
const OVERRIDABLE = ["SIMULATE_SENDS", "MIN_LEAD_SECONDS", "SUBREQUEST_BUDGET"];
|
|
52
|
+
const OVERRIDABLE = ["SIMULATE_SENDS", "SIMULATE_ACCOUNT", "MIN_LEAD_SECONDS", "SUBREQUEST_BUDGET"];
|
|
53
53
|
|
|
54
54
|
// Whether a TCP port can be bound on loopback right now. A momentary check, so what it
|
|
55
55
|
// reports can go stale before wrangler binds: the same check-then-bind race autoPort lives with.
|
package/cli/init.mjs
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* wrangler config and one-line Worker entry, `.dev.vars.example`, the Claude context with its
|
|
9
9
|
* headers (cli/context.mjs), and this release's migrations.
|
|
10
10
|
*
|
|
11
|
+
* The scaffold's wrangler config names the Worker, the D1 database, and the R2 bucket
|
|
12
|
+
* `kestrel…`; init renames each after the instance (the folder's name, or `--name`), so a
|
|
13
|
+
* second instance in the same Cloudflare account never deploys over the first or collides
|
|
14
|
+
* with its database and bucket. An instance already written keeps the names it has.
|
|
15
|
+
*
|
|
11
16
|
* A few files ship under other names and are renamed here: npm leaves `.gitignore` and
|
|
12
17
|
* `.npmrc` out of a published package, and the Claude files sit outside `.claude/` so they
|
|
13
18
|
* don't load into sessions in Kestrel's own repository. Init never overwrites: if any file it
|
|
@@ -62,9 +67,92 @@ function packageName(dir) {
|
|
|
62
67
|
return name || "newsletter";
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
// The longest name init derives is `<name>-media-production`, and an R2 bucket's name, like a
|
|
71
|
+
// Worker's on workers.dev, is at most 63 characters. Each resource takes lowercase letters,
|
|
72
|
+
// digits, and hyphens, never a hyphen at either end: the rules R2 and workers.dev share, and
|
|
73
|
+
// D1 accepts. Every name init makes ends in a suffix (`-dev`, `-production`), so no instance's
|
|
74
|
+
// name can make another's: the development Worker's `NAME-dev` never equals a production
|
|
75
|
+
// `OTHER-production`. `kestrel` alone is refused, being what instances made by earlier
|
|
76
|
+
// releases are named.
|
|
77
|
+
const NAME_MAX = 63 - "-media-production".length;
|
|
78
|
+
const NAME_RULE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
79
|
+
|
|
80
|
+
/** Why `name` can't name an instance's resources, or null when it can. */
|
|
81
|
+
export function nameRefusal(name) {
|
|
82
|
+
if (name.length > NAME_MAX) {
|
|
83
|
+
return `"${name}" is ${name.length} characters; an instance's name is at most ${NAME_MAX}`;
|
|
84
|
+
}
|
|
85
|
+
if (!NAME_RULE.test(name)) {
|
|
86
|
+
return `"${name}" isn't a valid instance name: use lowercase letters, digits, and hyphens, starting and ending with a letter or digit`;
|
|
87
|
+
}
|
|
88
|
+
if (name === "kestrel") {
|
|
89
|
+
return `"kestrel" names the instances made by earlier releases, so a new one takes a name of its own`;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The instance name a folder's name makes: accents dropped (`Café` is `cafe`), each run of
|
|
96
|
+
* other characters a hyphen, or `newsletter` when nothing is left.
|
|
97
|
+
*/
|
|
98
|
+
export function nameFromFolder(dir) {
|
|
99
|
+
const name = basename(dir)
|
|
100
|
+
.normalize("NFKD")
|
|
101
|
+
.replace(/\p{M}/gu, "")
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
104
|
+
.replace(/^-+/, "")
|
|
105
|
+
.slice(0, NAME_MAX)
|
|
106
|
+
.replace(/-+$/, "");
|
|
107
|
+
return name || "newsletter";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The scaffold's resource names: `kestrel`, then a suffix such as `-media-production`.
|
|
111
|
+
const RESOURCE_NAME = /("(?:name|database_name|bucket_name)":\s*")kestrel((?:-[a-z]+)*")/g;
|
|
112
|
+
const RESOURCE_COUNT = 6;
|
|
113
|
+
|
|
114
|
+
/** The scaffold's wrangler config with its Worker, database, and bucket named after `name`. */
|
|
115
|
+
export function nameResources(config, name) {
|
|
116
|
+
let count = 0;
|
|
117
|
+
const named = config.replace(RESOURCE_NAME, (_, key, suffix) => {
|
|
118
|
+
count++;
|
|
119
|
+
return `${key}${name}${suffix}`;
|
|
120
|
+
});
|
|
121
|
+
// A scaffold whose names this no longer finds would write an instance named `kestrel`.
|
|
122
|
+
if (count !== RESOURCE_COUNT) {
|
|
123
|
+
throw new Error(`template/wrangler.jsonc names ${count} resources, not ${RESOURCE_COUNT}`);
|
|
124
|
+
}
|
|
125
|
+
return named;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The instance directory and the `--name` flag's value, if given, from init's arguments. */
|
|
129
|
+
export function parseInitArgs(argv) {
|
|
130
|
+
let dir;
|
|
131
|
+
let name;
|
|
132
|
+
for (let i = 0; i < argv.length; i++) {
|
|
133
|
+
const arg = argv[i];
|
|
134
|
+
if (arg === "--name") {
|
|
135
|
+
name = argv[++i] ?? "";
|
|
136
|
+
} else if (arg.startsWith("--name=")) {
|
|
137
|
+
name = arg.slice("--name=".length);
|
|
138
|
+
} else if (!arg.startsWith("-") && dir === undefined) {
|
|
139
|
+
dir = arg;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { dir: dir ?? ".", name };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `kestrel init [dir] [--name <name>]`. */
|
|
66
146
|
export function initCommand(argv) {
|
|
67
|
-
const
|
|
147
|
+
const args = parseInitArgs(argv);
|
|
148
|
+
const root = resolve(args.dir);
|
|
149
|
+
const name = args.name ?? nameFromFolder(root);
|
|
150
|
+
const refusal = nameRefusal(name);
|
|
151
|
+
if (refusal) {
|
|
152
|
+
const hint = args.name === undefined ? " Name it with --name, or rename the folder." : "";
|
|
153
|
+
console.error(`[init] refused: ${refusal}.${hint}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
68
156
|
const files = [
|
|
69
157
|
...scaffoldFiles(),
|
|
70
158
|
[join(PACKAGE_ROOT, ".dev.vars.example"), ".dev.vars.example"],
|
|
@@ -93,12 +181,14 @@ export function initCommand(argv) {
|
|
|
93
181
|
// nodejs_compat) and stops ending the typecheck with "Action required"; the scaffold's
|
|
94
182
|
// tsconfig lists no `types`, so they never enter the program and the Worker's types win.
|
|
95
183
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
96
|
-
pkg.name = packageName(root);
|
|
184
|
+
pkg.name = args.name ?? packageName(root);
|
|
97
185
|
pkg.dependencies[PACKAGE.name] = PACKAGE.version;
|
|
98
186
|
pkg.devDependencies["@types/node"] = PACKAGE.devDependencies["@types/node"];
|
|
99
187
|
pkg.devDependencies.typescript = PACKAGE.devDependencies.typescript;
|
|
100
188
|
pkg.devDependencies.wrangler = PACKAGE.peerDependencies.wrangler;
|
|
101
189
|
writeFileSync(join(root, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
190
|
+
const config = join(root, "wrangler.jsonc");
|
|
191
|
+
writeFileSync(config, nameResources(readFileSync(config, "utf8"), name));
|
|
102
192
|
for (const file of CONTEXT_FILES) {
|
|
103
193
|
writeShipped(root, file);
|
|
104
194
|
}
|
|
@@ -107,7 +197,11 @@ export function initCommand(argv) {
|
|
|
107
197
|
// A path from here when the instance is below this directory, else the absolute one.
|
|
108
198
|
const rel = relative(process.cwd(), root);
|
|
109
199
|
const where = rel.startsWith("..") ? root : rel;
|
|
110
|
-
console.log(`[init] a Kestrel ${PACKAGE.version} instance${where ? ` in ${where}` : ""}
|
|
200
|
+
console.log(`[init] a Kestrel ${PACKAGE.version} instance${where ? ` in ${where}` : ""}.`);
|
|
201
|
+
console.log(
|
|
202
|
+
`[init] named ${name}${args.name === undefined ? " after its folder (--name picks another)" : ""}: ` +
|
|
203
|
+
`Worker and database ${name}-production, image bucket ${name}-media-production. Next:`,
|
|
204
|
+
);
|
|
111
205
|
console.log(
|
|
112
206
|
[
|
|
113
207
|
...(where ? [` cd ${where}`] : []),
|