diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4234a65..59d5106 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,15 +1,50 @@
# Changelog
-The three packages (`pgstencil`, `@pgstencil/auth`, `@pgstencil/stripe`) share one version. They are 0.x releases: a minor version may break compatibility, and each breaking change below says how an application adapts. Releases before 0.5.0 are recorded only in the Git history.
+The three packages (`pgstencil`, `@pgstencil/auth`, `@pgstencil/stripe`) share one version. They are 0.x releases: a minor version may break compatibility, and each breaking change below says how an application adapts. Releases before 0.5.0 are recorded only in the Git history, with one note for an application upgrading across them: since 0.4.0, `testcontainers`, `node-pg-migrate`, `cheerio` and `turndown` are optional peers that the application installs itself, usually as devDependencies ([PACKAGES.md](PACKAGES.md#tooling-peers)).
## Unreleased
Nothing yet.
+## 0.5.1 (2026-11-10)
+
+Fixes and compatible additions found while two applications adopted 0.5.0.
+
+### Fixed
+
+- **`pgstencil create` no longer writes into an installed package.** It wrote into the last configured source without checking, so a project whose sources were all package directories got a new SQL file inside `node_modules`. It now refuses any target inside `node_modules`, the default or one given with `--in`.
+- **`pgstencil status`, `validate` and `migrate` refuse a `DATABASE_URL` that is set but empty.** 0.5.0 passed the empty string to the driver, which then connected with its own defaults. An empty value is how a workflow sees a secret it lost.
+- **`pgstencil reset` refuses to run while `DATABASE_URL` is set.** It only ever recreates the development database and used to ignore the variable silently.
+- **`@pgstencil/auth`: the default sign-in sender is a valid address for an IP origin**: `signin@[127.0.0.1]`, or `signin@[IPv6:…]`, where it was `signin@127.0.0.1`. A non-blank `emailFrom` is trimmed.
+- **This changelog's 0.5.0 entry** gains the notes it was published without: the stripe 23 and Stripe API version move, the Better Auth pin, and the optional tooling peers introduced in 0.4.0.
+
+### Added
+
+- **`pgstencil status|validate|migrate --also
`**, repeatable: one more migration source after the configured ones for that run, for SQL only some databases get, such as previews.
+- **`pgstencil status|validate|migrate --require-database-url`**: fail when `DATABASE_URL` is unset instead of using the development database.
+- **`--env-file ` and `--env-file-if-exists `** on every `pgstencil` command, loaded before configuration is read.
+- **`pgstencil/email`** subpath: the email types, `EmailDev` and `escapeHtml` without the root's `node:crypto` import, for code a browser also loads. The `pgstencil` package also declares `"sideEffects": false`, so a production bundle that imports only `escapeHtml` from the root drops the rest.
+
+One message changed: `pgstencil status ` reports `status accepts only --also and --require-database-url` where it said `status takes no arguments`.
+
## 0.5.0 (2026-11-10)
This release is breaking.
+### Stripe 23 and a new Stripe API version
+
+These notes were missing when 0.5.0 was published.
+
+`@pgstencil/stripe` now peers on `stripe ^23.1.0` (0.4.3 accepted `^22.6.1`), so an application must move its own `stripe` dependency to 23. That moves `STRIPE_API_VERSION` to `2026-10-28.endive`, and `verifyWebhook` refuses an event at any other version, so this upgrade is a webhook cut-over, not a routine bump:
+
+- Create a webhook endpoint at `2026-10-28.endive` and give the new release its signing secret before it serves traffic, then retire the old endpoint. [BILLING.md](BILLING.md#moving-to-a-new-stripe-api-version) has the full procedure.
+- Checkout sessions send `allowed_payment_method_types: ['card']` where they sent `payment_method_types: ['card']`, which Stripe 23 removed. A Checkout creation whose response was lost just before the deploy is retried with the new parameters under its saved idempotency key, which Stripe refuses; repair that owner as for any ambiguous creation.
+- `verifyWebhook` passes its 300-second tolerance explicitly, so its behaviour is the same under stripe 23, which changed the SDK's default.
+- `Billing.reconcileAll({ dryRun, pageSize })` reconciles every owner holding a Stripe customer and reports what changed, for a one-off pass after a cut-over like this one.
+- StripeDev models the stripe 23 subscription and Checkout shapes, and gains `pause` and `resume` transitions.
+
+`@pgstencil/auth` moves its exact Better Auth pin from 1.7.16 to 1.7.18, which changes an application's lockfile and any dependency disclosure it publishes.
+
### Removed: the original code/link authentication
`@pgstencil/auth` is now only the Better Auth integration. The original implementation, which the earlier releases kept beside it, is gone:
diff --git a/PACKAGES.md b/PACKAGES.md
index 93a6580..9d5e6df 100644
--- a/PACKAGES.md
+++ b/PACKAGES.md
@@ -119,8 +119,9 @@ has the steps.
### Sign-in email
The email-code message is sent through the `email` sender you inject.
-`emailFrom` sets its sender; without it the sender is
-`signin@`, so a deployment that forgets it is refused by its
+`emailFrom` sets its sender, trimmed; without it, or when it is blank, the
+sender is `signin@` (`signin@[]` for an IPv4
+origin and `signin@[IPv6:]` for an IPv6 one), so a deployment that forgets it is refused by its
mail provider instead of sending as another domain. On Workers, `emailFrom` may
be `(env) => env.EMAIL_FROM`. `postmarkEmail(token, from)` always sends from its
own `from` argument, whatever the message says.
@@ -131,7 +132,9 @@ or a promise of one; `defaultSignInEmail` is the built-in renderer, which a
custom one can call and adjust. The message must be addressed to `email` alone,
with no `cc` or `bcc`: any other recipient, or a renderer that throws, delivers
nothing and records an `email.delivery.failed` diagnostic. Escape every value
-you interpolate into `html` with `escapeHtml` from `pgstencil`. Better Auth
+you interpolate into `html` with `escapeHtml` from `pgstencil`, or from
+`pgstencil/email` in code a browser also loads: that subpath exports the email
+types, `EmailDev` and `escapeHtml` and imports no Node built-in. Better Auth
answers a send request 200 whether or not delivery succeeded, so watch that
diagnostic rather than the response.
diff --git a/README.md b/README.md
index e9d1f35..2e5b1df 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ pnpm db:migrate
}
```
-`migrations` is one directory or an ordered list, each inside the project root; it defaults to `migrations`. List a package's source by its installed path and your own directory last, since that is where `create` writes. `schema` names the schema dump. Each `types` entry generates Kysely declarations to `out`, for the tables matching `include` (default `public.*`), minus `exclude`, with `defaultSchema` set when the tables live outside `public`; `types` needs the optional peer `kysely-codegen`. No other key is accepted. The project root is the working directory, or `PGSTENCIL_PROJECT_ROOT`.
+`migrations` is one directory or an ordered list, each inside the project root; it defaults to `migrations`. List a package's source by its installed path, which in a pnpm workspace is under the `node_modules` of the workspace package that depends on it (`app/node_modules/@pgstencil/…` when the project root is the workspace root). List your own directory last, since that is where `create` writes; it refuses a target inside `node_modules`. `schema` names the schema dump. Each `types` entry generates Kysely declarations to `out`, for the tables matching `include` (default `public.*`), minus `exclude`, with `defaultSchema` set when the tables live outside `public`; `types` needs the optional peer `kysely-codegen`. No other key is accepted. The project root is the working directory, or `PGSTENCIL_PROJECT_ROOT`.
The schema dump and generated declarations are committed review artifacts, not schema inputs, so editor typechecking works without Docker. Each new migration fingerprint gets its own template; unchanged fingerprints reuse a ready template without rerunning initialization. Failed initializers are discarded, and interrupted initializers without a ready marker are rebuilt. Template cache identity includes SQL filenames/content, the Compose configuration and a format version.
@@ -136,7 +136,12 @@ Persistent development/production databases record applied-file checksums. Editi
| `pgstencil gc` | `db:gc` | Clear cached test templates/databases when idle |
| `pgstencil stop` | `db:stop` | Stop this checkout's services when idle; retain its Postgres volume |
-`DATABASE_URL` directs `status`, `validate` and `migrate` at an explicitly supplied persistent database. Otherwise they use `pgstencil_dev`. Schema/type commands always use a disposable migrated clone. Never point template management at a production cluster. `pnpm check` runs formatting, typechecking and all tests.
+`DATABASE_URL` directs `status`, `validate` and `migrate` at an explicitly supplied persistent database. Otherwise they use `pgstencil_dev`. A `DATABASE_URL` that is set but empty is refused, because that is how a workflow sees a secret it lost. Those three commands also take:
+
+- `--also `, repeatable: one more migration source after the configured ones, for this run only, such as SQL that only preview databases get. A database that has applied files from an `--also` directory needs the same option on every later run, or validation reports them missing.
+- `--require-database-url`: fail when `DATABASE_URL` is unset instead of using the development database. Use it in every deployment script.
+
+Every command takes `--env-file ` and `--env-file-if-exists `, repeatable, loaded before `pgstencil.json` is read, so a file can set `PGSTENCIL_PROJECT_ROOT` and `DATABASE_URL`. Variables already in the environment win, and a later file wins over an earlier one. Node itself also notices these two flags: a missing `--env-file` stops in Node with exit code 9, and a missing `--env-file-if-exists` prints Node's own notice. `reset` refuses to run while `DATABASE_URL` is set, since it only ever recreates the development database. Schema/type commands always use a disposable migrated clone. Never point template management at a production cluster. `pnpm check` runs formatting, typechecking and all tests.
Local services run PostgreSQL 18. The 18 image keeps its data under `/var/lib/postgresql/18/docker` and refuses a volume holding a 17 cluster, so the volume is named `postgres-18-data`. A checkout that last ran PostgreSQL 17 starts a fresh development database; its old volume stays until removed with `docker volume rm _postgres-data`, and `docker volume ls` lists it.
diff --git a/packages/auth/package.json b/packages/auth/package.json
index 2861d60..a5365bf 100644
--- a/packages/auth/package.json
+++ b/packages/auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@pgstencil/auth",
- "version": "0.5.0",
+ "version": "0.5.1",
"type": "module",
"exports": {
".": "./src/index.ts",
diff --git a/packages/auth/src/better-auth-email.ts b/packages/auth/src/better-auth-email.ts
index c403cbb..d324176 100644
--- a/packages/auth/src/better-auth-email.ts
+++ b/packages/auth/src/better-auth-email.ts
@@ -13,6 +13,22 @@ export function isIdentityEmail(email: string) {
return domain === identityDomain || domain.endsWith('.' + identityDomain);
}
+/**
+ * The sign-in email's sender: a non-blank `emailFrom`, trimmed, or
+ * `signin@`. An IP origin gets an RFC 5321 address
+ * literal (`signin@[127.0.0.1]`, `signin@[IPv6:::1]`), since a bare IP is not
+ * a valid domain. `origin` is already canonical, so the URL parser has
+ * normalized an IPv4 host to dotted decimal and bracketed an IPv6 one.
+ */
+export function signInSender(emailFrom: unknown, origin: string): string {
+ const configured = typeof emailFrom === 'string' ? emailFrom.trim() : '';
+ if (configured) return configured;
+ const host = new URL(origin).hostname;
+ if (host.startsWith('[')) return `signin@[IPv6:${host.slice(1, -1)}]`;
+ if (/^[\d.]+$/.test(host)) return `signin@[${host}]`;
+ return `signin@${host}`;
+}
+
/** Better Auth requires an email column, even for a provider-only account.
* This reserved address identifies an account; it is never a delivery address.
* Include the client ID because provider subjects can be scoped to an app.
diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts
index f3db560..7cd4ba9 100644
--- a/packages/auth/src/better-auth-workers.ts
+++ b/packages/auth/src/better-auth-workers.ts
@@ -46,8 +46,9 @@ export function createBetterAuthWorker(
email: (env: E) => EmailSender;
/**
* The default sign-in email's sender, usually read from a binding such as
- * `(env) => env.EMAIL_FROM`. When omitted, `undefined` or blank, it is
- * `signin@`. `renderEmail` receives it as `from`.
+ * `(env) => env.EMAIL_FROM`, and trimmed. When omitted, `undefined` or
+ * blank, it is `signin@`, as `emailFrom` describes
+ * for `createAuthApp`. `renderEmail` receives it as `from`.
*/
emailFrom?: string | ((env: E) => string);
/** Builds this request's hooks; their pending work is kept alive with `waitUntil`. */
diff --git a/packages/auth/src/better-auth.ts b/packages/auth/src/better-auth.ts
index 500eb14..f1ebe13 100644
--- a/packages/auth/src/better-auth.ts
+++ b/packages/auth/src/better-auth.ts
@@ -33,6 +33,7 @@ import {
identityEmail,
isIdentityEmail,
providerSubject,
+ signInSender,
} from './better-auth-email.ts';
/** How a login proved identity: an email code, or a provider's ID. */
@@ -62,7 +63,10 @@ export interface SignInEmailInput {
expiresInMinutes: number;
/** `AuthOptions.appName`, when set. Escape it with `escapeHtml` before placing it in HTML. */
appName?: string;
- /** The resolved sender: `emailFrom`, or `signin@`. */
+ /**
+ * The resolved sender: `emailFrom` trimmed, or `signin@`
+ * (`signin@[]` or `signin@[IPv6:]` for an IP origin).
+ */
from: string;
}
@@ -110,9 +114,7 @@ async function sendSignInEmail(
otp,
expiresInMinutes: otpExpiresInSeconds / 60,
...(options.appName ? { appName: options.appName } : {}),
- from: options.emailFrom?.trim()
- ? options.emailFrom
- : `signin@${new URL(options.origin).hostname}`,
+ from: signInSender(options.emailFrom, options.origin),
};
message = await (options.renderEmail ?? defaultSignInEmail)(input);
if (!addressedOnlyTo(message, email))
@@ -164,11 +166,13 @@ export interface AuthOptions {
appName?: string;
/**
* The sender of the default sign-in email, and `from` in `renderEmail`'s
- * input. Defaults to `signin@`, so a deployment that
- * forgets it is refused by its mail provider rather than sending as another
- * domain. A blank string counts as omitted, so a preview that blanks its
- * mail bindings keeps working. `postmarkEmail`'s own `from` argument
- * overrides any message sender.
+ * input. Defaults to `signin@`, or an address literal
+ * such as `signin@[127.0.0.1]` or `signin@[IPv6:::1]` for an IP origin, so a
+ * deployment that forgets it is refused by its mail provider rather than
+ * sending as another domain. Surrounding whitespace is trimmed, and a blank
+ * string counts as omitted, so a preview that blanks its mail bindings keeps
+ * working. `postmarkEmail`'s own `from` argument overrides any message
+ * sender.
*/
emailFrom?: string;
/**
diff --git a/packages/pgstencil/package.json b/packages/pgstencil/package.json
index 32949e7..78cd02a 100644
--- a/packages/pgstencil/package.json
+++ b/packages/pgstencil/package.json
@@ -1,14 +1,16 @@
{
"name": "pgstencil",
- "version": "0.5.0",
+ "version": "0.5.1",
"type": "module",
+ "sideEffects": false,
"exports": {
".": "./src/index.ts",
"./database": "./src/database.ts",
"./snapshots": "./src/snapshots.ts",
"./testing": "./src/testing.ts",
"./postgres": "./src/postgres.ts",
- "./diagnostics": "./src/diagnostics.ts"
+ "./diagnostics": "./src/diagnostics.ts",
+ "./email": "./src/email.ts"
},
"bin": {
"pgstencil": "./src/cli.ts"
@@ -83,6 +85,10 @@
"./diagnostics": {
"types": "./dist/diagnostics.d.ts",
"import": "./dist/diagnostics.js"
+ },
+ "./email": {
+ "types": "./dist/email.d.ts",
+ "import": "./dist/email.js"
}
},
"bin": {
diff --git a/packages/pgstencil/src/arguments.ts b/packages/pgstencil/src/arguments.ts
index 5368757..8fd43d0 100644
--- a/packages/pgstencil/src/arguments.ts
+++ b/packages/pgstencil/src/arguments.ts
@@ -1,12 +1,26 @@
+/** An environment file named by `--env-file` or `--env-file-if-exists`. */
+export interface EnvFile {
+ path: string;
+ /** From `--env-file-if-exists`: a missing file is skipped. */
+ optional: boolean;
+}
+
/** The `pgstencil` command line, parsed without touching configuration or Docker. */
-export type Command =
+export type Command = (
| {
- name:
- 'status' | 'validate' | 'migrate' | 'verify' | 'reset' | 'gc' | 'stop';
+ name: 'status' | 'validate' | 'migrate';
+ /** `--also` directories, as given, in order. */
+ also?: string[];
+ requireDatabaseUrl?: true;
}
+ | { name: 'verify' | 'reset' | 'gc' | 'stop' }
| { name: 'create'; label: string; in?: string }
| { name: 'schema' | 'types'; verify: boolean }
- | { name: 'help' };
+ | { name: 'help' }
+) & {
+ /** Global `--env-file` and `--env-file-if-exists` options, in order. */
+ envFiles?: EnvFile[];
+};
export const usage = `Usage: pgstencil [options]
@@ -15,7 +29,7 @@ Commands:
validate Check applied migrations still match their SQL files
migrate Apply pending migrations
create [--in ] Add an empty numbered migration to , by default
- the last configured source
+ the last configured source; never inside node_modules
schema [--verify] Write, or verify, the schema dump
types [--verify] Write, or verify, the Kysely declarations
verify Verify the schema dump and declarations together
@@ -24,25 +38,54 @@ Commands:
stop Stop this project's services when no database is in use
help Show this message
+Options for status, validate and migrate:
+ --also Read as one more migration source, after the
+ configured ones, for this run only; repeatable
+ --require-database-url Fail when DATABASE_URL is unset instead of using the
+ development database
+
+Options for every command:
+ --env-file Load environment variables from before reading
+ configuration; variables already set win, and a later
+ file wins over an earlier one; repeatable
+ --env-file-if-exists The same, skipping a file that does not exist
+
status, validate and migrate use DATABASE_URL when it is set, otherwise the
-development database. schema, types and verify use a disposable database built
-from the configured migrations. Configuration is read from pgstencil.json in
-PGSTENCIL_PROJECT_ROOT, or the current directory.`;
+development database; a DATABASE_URL that is set but empty is refused, since it
+usually means a missing secret. reset only ever recreates the development
+database, so it refuses to run while DATABASE_URL is set. schema, types and
+verify use a disposable database built from the configured migrations.
+Configuration is read from pgstencil.json in PGSTENCIL_PROJECT_ROOT, or the
+current directory.`;
+
+/**
+ * Loads `--env-file` and `--env-file-if-exists` files with Node's precedence:
+ * the environment wins over every file, and a later file over an earlier one.
+ * `process.loadEnvFile` never overwrites a variable, so load the last first.
+ * Node itself also checks these flags after a script name: it exits on a
+ * missing `--env-file` before pgstencil runs, but never loads the file.
+ */
+export function loadEnvFiles(files: readonly EnvFile[]): void {
+ for (const file of [...files].reverse()) {
+ try {
+ process.loadEnvFile(file.path);
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
+ if (!file.optional)
+ throw new Error(`--env-file ${file.path} does not exist`, {
+ cause: error,
+ });
+ }
+ }
+}
/** A command line pgstencil cannot run; the CLI prints it with the usage text. */
export class UsageError extends Error {
override name = 'UsageError';
}
-const PLAIN = new Set([
- 'status',
- 'validate',
- 'migrate',
- 'verify',
- 'reset',
- 'gc',
- 'stop',
-]);
+const INSPECT = new Set(['status', 'validate', 'migrate']);
+const PLAIN = new Set(['verify', 'reset', 'gc', 'stop']);
const LABEL = /^[a-z][a-z0-9_]*$/;
function noArguments(name: string, rest: readonly string[]): void {
@@ -52,8 +95,47 @@ function noArguments(name: string, rest: readonly string[]): void {
);
}
+/**
+ * Reads `--