@saastemly/voidcommerce 0.1.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/README.md +271 -0
- package/bin/vc +2 -0
- package/dist/catalog.d.ts +69 -0
- package/dist/catalog.js +34 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/deploy/cloudflare.d.ts +25 -0
- package/dist/deploy/index.d.ts +16 -0
- package/dist/deploy/jsonc.d.ts +8 -0
- package/dist/deploy/preflight.d.ts +29 -0
- package/dist/deploy/wrangler.d.ts +37 -0
- package/dist/dist.d.ts +22 -0
- package/dist/generate/auth.d.ts +2 -0
- package/dist/generate/ci.d.ts +24 -0
- package/dist/generate/env.d.ts +13 -0
- package/dist/generate/frontend.d.ts +47 -0
- package/dist/generate/index.d.ts +28 -0
- package/dist/generate/requirements.d.ts +13 -0
- package/dist/generate/strict.d.ts +72 -0
- package/dist/generate/support.d.ts +23 -0
- package/dist/help.d.ts +31 -0
- package/dist/import.d.ts +2 -0
- package/dist/index-s7sq41qs.js +590 -0
- package/dist/index-ssv3a6wc.js +172 -0
- package/dist/index-wzy1xtr1.js +3155 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +190 -0
- package/dist/init.d.ts +1 -0
- package/dist/manifest.d.ts +131 -0
- package/dist/manifest.js +41 -0
- package/dist/project.d.ts +20 -0
- package/dist/regenerate.d.ts +9 -0
- package/dist/scripts.d.ts +12 -0
- package/dist/void.d.ts +30 -0
- package/dist/wizard.d.ts +7 -0
- package/package.json +50 -0
- package/src/catalog.ts +673 -0
- package/src/cli.ts +78 -0
- package/src/deploy/cloudflare.ts +166 -0
- package/src/deploy/index.ts +101 -0
- package/src/deploy/jsonc.ts +148 -0
- package/src/deploy/preflight.ts +137 -0
- package/src/deploy/wrangler.ts +111 -0
- package/src/dist.ts +157 -0
- package/src/generate/auth.ts +386 -0
- package/src/generate/ci.ts +208 -0
- package/src/generate/env.ts +164 -0
- package/src/generate/frontend.ts +275 -0
- package/src/generate/index.ts +390 -0
- package/src/generate/requirements.ts +48 -0
- package/src/generate/strict.ts +692 -0
- package/src/generate/support.ts +252 -0
- package/src/help.ts +172 -0
- package/src/import.ts +237 -0
- package/src/index.ts +37 -0
- package/src/init.ts +187 -0
- package/src/manifest.ts +303 -0
- package/src/project.ts +63 -0
- package/src/regenerate.ts +51 -0
- package/src/scripts.ts +53 -0
- package/src/void.ts +115 -0
- package/src/wizard.ts +234 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* wrangler, as vc uses it: found, asked who it is, and driven for the
|
|
7
|
+
* account operations a deploy needs. Nothing here needs a Void account.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function findWrangler(from = process.cwd()): string | null {
|
|
11
|
+
let dir = from;
|
|
12
|
+
for (;;) {
|
|
13
|
+
const local = join(dir, "node_modules", ".bin", "wrangler");
|
|
14
|
+
if (existsSync(local)) return local;
|
|
15
|
+
const parent = dirname(dir);
|
|
16
|
+
if (parent === dir) break;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
const onPath = (process.env["PATH"] ?? "")
|
|
20
|
+
.split(delimiter)
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.some((d) => existsSync(join(d, "wrangler")));
|
|
23
|
+
return onPath ? "wrangler" : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Ran {
|
|
27
|
+
code: number;
|
|
28
|
+
out: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Run wrangler. `inherit` hands the terminal over (prompts, progress); otherwise output is captured. */
|
|
32
|
+
export function wrangler(bin: string, args: string[], cwd: string, inherit = false): Promise<Ran> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const child = spawn(bin, args, { cwd, stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"] });
|
|
35
|
+
let out = "";
|
|
36
|
+
child.stdout?.on("data", (chunk) => {
|
|
37
|
+
out += chunk;
|
|
38
|
+
});
|
|
39
|
+
child.stderr?.on("data", (chunk) => {
|
|
40
|
+
out += chunk;
|
|
41
|
+
});
|
|
42
|
+
child.on("error", (error) => resolve({ code: 1, out: String(error) }));
|
|
43
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, out }));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WhoAmI {
|
|
48
|
+
state: "logged-in" | "logged-out" | "unknown";
|
|
49
|
+
accounts: Array<{ name: string; id: string }>;
|
|
50
|
+
raw: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `wrangler whoami` exits 0 whether or not you are logged in, so the text
|
|
55
|
+
* decides. Logged in, it prints a table of accounts; those are what pinning
|
|
56
|
+
* needs.
|
|
57
|
+
*/
|
|
58
|
+
export function parseWhoAmI(raw: string): WhoAmI {
|
|
59
|
+
if (/not authenticated/i.test(raw)) return { state: "logged-out", accounts: [], raw };
|
|
60
|
+
const accounts = [...raw.matchAll(/│\s*([^│\n]+?)\s*│\s*([0-9a-f]{32})\s*│/g)].map((m) => ({ name: m[1]!, id: m[2]! }));
|
|
61
|
+
if (/logged in/i.test(raw) || accounts.length > 0) return { state: "logged-in", accounts, raw };
|
|
62
|
+
return { state: "unknown", accounts: [], raw };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function whoami(bin: string, cwd: string): Promise<WhoAmI> {
|
|
66
|
+
return parseWhoAmI((await wrangler(bin, ["whoami"], cwd)).out);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Secret names on the worker, or null when they cannot be listed — a worker not deployed yet says so. */
|
|
70
|
+
export async function secretNames(bin: string, cwd: string): Promise<Set<string> | null> {
|
|
71
|
+
const ran = await wrangler(bin, ["secret", "list", "--format", "json"], cwd);
|
|
72
|
+
if (ran.code !== 0) return null;
|
|
73
|
+
try {
|
|
74
|
+
const start = ran.out.indexOf("[");
|
|
75
|
+
const rows = JSON.parse(ran.out.slice(start)) as Array<{ name: string }>;
|
|
76
|
+
return new Set(rows.map((row) => row.name));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface D1 {
|
|
83
|
+
name: string;
|
|
84
|
+
uuid: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function d1List(bin: string, cwd: string): Promise<D1[]> {
|
|
88
|
+
const ran = await wrangler(bin, ["d1", "list", "--json"], cwd);
|
|
89
|
+
if (ran.code !== 0) throw new Error(`wrangler d1 list failed:\n${ran.out.trim()}`);
|
|
90
|
+
const start = ran.out.indexOf("[");
|
|
91
|
+
return JSON.parse(ran.out.slice(start)) as D1[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The database by name, created if absent. Idempotent. */
|
|
95
|
+
export async function ensureD1(bin: string, cwd: string, name: string): Promise<D1> {
|
|
96
|
+
const existing = (await d1List(bin, cwd)).find((db) => db.name === name);
|
|
97
|
+
if (existing) return existing;
|
|
98
|
+
const created = await wrangler(bin, ["d1", "create", name], cwd);
|
|
99
|
+
if (created.code !== 0) throw new Error(`wrangler d1 create ${name} failed:\n${created.out.trim()}`);
|
|
100
|
+
const found = (await d1List(bin, cwd)).find((db) => db.name === name);
|
|
101
|
+
if (!found) throw new Error(`created D1 "${name}" but it is not in wrangler d1 list`);
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A queue by name. Already existing is fine. */
|
|
106
|
+
export async function ensureQueue(bin: string, cwd: string, name: string): Promise<void> {
|
|
107
|
+
const ran = await wrangler(bin, ["queues", "create", name], cwd);
|
|
108
|
+
if (ran.code !== 0 && !/already exists|11009/i.test(ran.out)) {
|
|
109
|
+
throw new Error(`wrangler queues create ${name} failed:\n${ran.out.trim()}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/dist.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
import color from "picocolors";
|
|
5
|
+
import { generate } from "./generate/index";
|
|
6
|
+
import { finishStrict } from "./generate/strict";
|
|
7
|
+
import { box, line, row } from "./help";
|
|
8
|
+
import { findProject } from "./project";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `vc dist` — the deployable app, as a self-contained tree.
|
|
12
|
+
*
|
|
13
|
+
* A strict repository has no app in it: `.vc/app` is generated and
|
|
14
|
+
* gitignored, and it reaches the root through symlinks. Nothing can build
|
|
15
|
+
* that except a machine that has run `vc generate` first.
|
|
16
|
+
*
|
|
17
|
+
* So this materialises it: the generated app with every symlink replaced by
|
|
18
|
+
* the real thing, a package.json carrying the versions the repository
|
|
19
|
+
* resolved, and the lockfile beside it. The result is an ordinary Void app
|
|
20
|
+
* that `bun install && vp build && wrangler deploy` can take from a cold
|
|
21
|
+
* checkout — which is exactly what a CI runner, or Cloudflare's own build,
|
|
22
|
+
* has.
|
|
23
|
+
*
|
|
24
|
+
* The generated workflow pushes this tree to a branch (`void-dist`) on every
|
|
25
|
+
* push to main, so the thing Cloudflare builds is a plain Void app and the
|
|
26
|
+
* thing a person edits stays a manifest.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const DIST_DIR = join(".vc", "dist");
|
|
30
|
+
export const DIST_BRANCH = "void-dist";
|
|
31
|
+
|
|
32
|
+
/** Never copied into the distributed tree, whatever the layout put there. */
|
|
33
|
+
const EXCLUDE = new Set(["node_modules", ".env", ".env.local", "dist", ".void", ".wrangler", ".git"]);
|
|
34
|
+
|
|
35
|
+
const README = (domain: string) => `# ${domain} — generated
|
|
36
|
+
|
|
37
|
+
Do not edit, and do not open a pull request against this branch. Every file
|
|
38
|
+
here is written by \`vc dist\` from the manifest on \`main\`, and this branch is
|
|
39
|
+
replaced wholesale on every push.
|
|
40
|
+
|
|
41
|
+
It is an ordinary Void app so that a build machine needs nothing but a
|
|
42
|
+
checkout:
|
|
43
|
+
|
|
44
|
+
\`\`\`sh
|
|
45
|
+
bun install
|
|
46
|
+
bunx void prepare
|
|
47
|
+
bunx vp build
|
|
48
|
+
bunx wrangler deploy -c dist/ssr/wrangler.json
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
Secrets are the worker's own (\`wrangler secret put\`), never files here. The
|
|
52
|
+
committed migrations under \`db/migrations\` are what production applies.
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Copy a directory, following symlinks into real files and skipping what a
|
|
57
|
+
* build must not carry. `cp -r` would copy the symlinks themselves, which
|
|
58
|
+
* point outside the tree and break the moment it is checked out elsewhere.
|
|
59
|
+
*/
|
|
60
|
+
async function copyResolved(from: string, to: string): Promise<void> {
|
|
61
|
+
await mkdir(to, { recursive: true });
|
|
62
|
+
for (const entry of await readdir(from, { withFileTypes: true })) {
|
|
63
|
+
if (EXCLUDE.has(entry.name)) continue;
|
|
64
|
+
const source = join(from, entry.name);
|
|
65
|
+
const target = join(to, entry.name);
|
|
66
|
+
// `dereference` resolves the link; `recursive` walks a directory.
|
|
67
|
+
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
|
68
|
+
const stat = await import("node:fs/promises").then((fs) => fs.stat(source).catch(() => null));
|
|
69
|
+
if (!stat) continue; // a dangling link is not an error worth failing a build for
|
|
70
|
+
if (stat.isDirectory()) {
|
|
71
|
+
await copyResolved(source, target);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await cp(source, target, { dereference: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function distCommand(args: string[]): Promise<number> {
|
|
80
|
+
const project = await findProject();
|
|
81
|
+
if (!project) {
|
|
82
|
+
console.error("vc: no voidcommerce.json here.");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const outIndex = args.indexOf("--out");
|
|
86
|
+
const out = outIndex >= 0 ? args[outIndex + 1] : undefined;
|
|
87
|
+
if (outIndex >= 0 && !out) {
|
|
88
|
+
console.error("vc: --out needs a directory.");
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
const target = join(project.root, out ?? DIST_DIR);
|
|
92
|
+
|
|
93
|
+
// Always regenerate: the point is that the tree matches the manifest.
|
|
94
|
+
await generate(project.root, project.manifest);
|
|
95
|
+
const prepared = await finishStrict(project.root);
|
|
96
|
+
if (prepared !== 0) return prepared;
|
|
97
|
+
|
|
98
|
+
await rm(target, { recursive: true, force: true });
|
|
99
|
+
await copyResolved(project.appDir, target);
|
|
100
|
+
|
|
101
|
+
// The app's package.json lists what the choices need, at "latest". The
|
|
102
|
+
// repository's own package.json holds what was actually resolved — pins,
|
|
103
|
+
// and links to packages that are not published. Those are what a cold
|
|
104
|
+
// install must reproduce, so they win.
|
|
105
|
+
const rootPkg = JSON.parse(await readFile(join(project.root, "package.json"), "utf8")) as Record<string, Record<string, string>>;
|
|
106
|
+
const appPkgPath = join(target, "package.json");
|
|
107
|
+
const appPkg = JSON.parse(await readFile(appPkgPath, "utf8")) as Record<string, unknown>;
|
|
108
|
+
const local: string[] = [];
|
|
109
|
+
for (const section of ["dependencies", "devDependencies"]) {
|
|
110
|
+
const merged = { ...((appPkg[section] as Record<string, string>) ?? {}) };
|
|
111
|
+
for (const [name, version] of Object.entries(rootPkg[section] ?? {})) {
|
|
112
|
+
if (name === "@saastemly/voidcommerce") continue; // the generator is not the app's dependency
|
|
113
|
+
if (name in merged || section === "devDependencies") merged[name] = version;
|
|
114
|
+
if (version.startsWith("file:") || version.startsWith("link:")) local.push(`${name}@${version}`);
|
|
115
|
+
}
|
|
116
|
+
appPkg[section] = Object.fromEntries(Object.entries(merged).sort());
|
|
117
|
+
}
|
|
118
|
+
if (rootPkg["patchedDependencies"]) appPkg["patchedDependencies"] = rootPkg["patchedDependencies"];
|
|
119
|
+
await writeFile(appPkgPath, `${JSON.stringify(appPkg, null, 2)}\n`, "utf8");
|
|
120
|
+
|
|
121
|
+
// The patches the pins refer to, and the lockfile, so the install is the
|
|
122
|
+
// one that was tested rather than whatever resolves today.
|
|
123
|
+
for (const file of ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml"]) {
|
|
124
|
+
const source = join(project.root, file);
|
|
125
|
+
if (existsSync(source)) await cp(source, join(target, file));
|
|
126
|
+
}
|
|
127
|
+
if (existsSync(join(project.root, "patches"))) await copyResolved(join(project.root, "patches"), join(target, "patches"));
|
|
128
|
+
await writeFile(join(target, "README.md"), README(project.manifest.shop.domain), "utf8");
|
|
129
|
+
await writeFile(join(target, ".gitignore"), "node_modules\ndist\n.void\n.wrangler\n.env\n.env.local\n", "utf8");
|
|
130
|
+
|
|
131
|
+
console.log(`${color.green("✓")} ${relative(project.root, target) || target} — a self-contained Void app`);
|
|
132
|
+
if (local.length > 0) {
|
|
133
|
+
console.log(
|
|
134
|
+
color.yellow(
|
|
135
|
+
`\n! ${local.length} dependenc${local.length === 1 ? "y is" : "ies are"} local paths and will not install from a fresh checkout:\n ${local.join("\n ")}\n Publish them, or point them at a registry, before a build machine uses this tree.`,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function distHelp(): Promise<number> {
|
|
143
|
+
const width = 80;
|
|
144
|
+
console.log(
|
|
145
|
+
box("vc dist", [
|
|
146
|
+
line("The deployable app as a self-contained tree: symlinks resolved, the", width),
|
|
147
|
+
line("repository's resolved versions, the lockfile, the patches.", width),
|
|
148
|
+
line("", width),
|
|
149
|
+
...row("vc dist", `writes ${DIST_DIR}`, width, 2),
|
|
150
|
+
...row("vc dist --out <dir>", "writes it somewhere else", width, 2),
|
|
151
|
+
line("", width),
|
|
152
|
+
line(`The generated workflow pushes this to the \`${DIST_BRANCH}\` branch on every`, width),
|
|
153
|
+
line("push to main, so Cloudflare builds a plain Void app and you edit a manifest.", width),
|
|
154
|
+
], width),
|
|
155
|
+
);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { type Manifest, has } from "../manifest";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `auth.ts` — the plugin ring, wired from the manifest.
|
|
5
|
+
*
|
|
6
|
+
* ── Generated INTO a file you own ────────────────────────────────────────
|
|
7
|
+
*
|
|
8
|
+
* Void will not scan a `plugins/` directory — its convention set is closed —
|
|
9
|
+
* and a Vite plugin emitting a virtual module would be hidden machinery that
|
|
10
|
+
* makes a future merge into Void harder, not easier. So this is written where
|
|
11
|
+
* a reader can see it, the way `rails generate` writes into files you own.
|
|
12
|
+
* Regenerate from the manifest; do not hand-edit the marked region.
|
|
13
|
+
*
|
|
14
|
+
* Every choice the wizard made shows up here as a plugin call with the same
|
|
15
|
+
* comment the wizard showed. The file explains itself to the next reader.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
interface Line {
|
|
19
|
+
/** The import statement. */
|
|
20
|
+
import?: string | undefined;
|
|
21
|
+
/** The call inside the relevant plugins array. Empty for an import-only line. */
|
|
22
|
+
call: string;
|
|
23
|
+
/** Which array it goes in. */
|
|
24
|
+
ring: "auth" | "commerce";
|
|
25
|
+
/** One line of why. */
|
|
26
|
+
why: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rates by country, so a fresh deploy is never tax-free. Standard VAT only. */
|
|
30
|
+
const VAT: Record<string, { rate: number; name: string }> = {
|
|
31
|
+
DK: { rate: 2500, name: "Moms" },
|
|
32
|
+
SE: { rate: 2500, name: "Moms" },
|
|
33
|
+
NO: { rate: 2500, name: "MVA" },
|
|
34
|
+
FI: { rate: 2550, name: "ALV" },
|
|
35
|
+
DE: { rate: 1900, name: "MwSt" },
|
|
36
|
+
NL: { rate: 2100, name: "BTW" },
|
|
37
|
+
FR: { rate: 2000, name: "TVA" },
|
|
38
|
+
GB: { rate: 2000, name: "VAT" },
|
|
39
|
+
IE: { rate: 2300, name: "VAT" },
|
|
40
|
+
ES: { rate: 2100, name: "IVA" },
|
|
41
|
+
IT: { rate: 2200, name: "IVA" },
|
|
42
|
+
AT: { rate: 2000, name: "USt" },
|
|
43
|
+
BE: { rate: 2100, name: "BTW" },
|
|
44
|
+
PL: { rate: 2300, name: "VAT" },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const social = (manifest: Manifest, id: string, envPrefix: string) =>
|
|
48
|
+
has(manifest, id)
|
|
49
|
+
? ` ${id}: { clientId: must(env, "${envPrefix}_CLIENT_ID"), clientSecret: must(env, "${envPrefix}_CLIENT_SECRET") },`
|
|
50
|
+
: null;
|
|
51
|
+
|
|
52
|
+
export function renderAuthTs(manifest: Manifest): string {
|
|
53
|
+
const { shop } = manifest;
|
|
54
|
+
const lines: Line[] = [];
|
|
55
|
+
/**
|
|
56
|
+
* The token route, when it was chosen. It wraps the PHYSICAL provider
|
|
57
|
+
* rather than sitting beside it, because the buyer's choice lives on the
|
|
58
|
+
* line: one provider reads it and sends the line down one road or the
|
|
59
|
+
* other. `mintProvider` is the app's to configure — the default records
|
|
60
|
+
* the intent and mints nothing, which is the honest state for a shop that
|
|
61
|
+
* has not chosen a chain.
|
|
62
|
+
*/
|
|
63
|
+
const wantsNft = has(manifest, "nft");
|
|
64
|
+
const nftProvider = wantsNft
|
|
65
|
+
? `, nftOrPhysical({ mint: mintProvider, physical: ${has(manifest, "business-central") ? `erpFulfillment("${shop.currency.toUpperCase()}")` : "manualFulfillment()"} })`
|
|
66
|
+
: "";
|
|
67
|
+
const nftDefault = wantsNft ? `, defaultProvider: "nft-or-physical"` : "";
|
|
68
|
+
|
|
69
|
+
const vat = VAT[shop.country];
|
|
70
|
+
|
|
71
|
+
// ── auth ring ────────────────────────────────────────────────────────
|
|
72
|
+
lines.push({
|
|
73
|
+
import: `import { admin } from "better-auth/plugins/admin";`,
|
|
74
|
+
call: `admin()`,
|
|
75
|
+
ring: "auth",
|
|
76
|
+
why: "who may open the panel",
|
|
77
|
+
});
|
|
78
|
+
lines.push({
|
|
79
|
+
import: `import { systemUser } from "@saastemly/better-system-user";`,
|
|
80
|
+
call: `systemUser({ key: read(env, "SYSTEM_API_KEY"), email: "system@${shop.domain}", name: "${shop.name} system" })`,
|
|
81
|
+
ring: "auth",
|
|
82
|
+
why: "the deployment authenticates as itself — no human sign-in to bootstrap",
|
|
83
|
+
});
|
|
84
|
+
if (has(manifest, "magic-link"))
|
|
85
|
+
lines.push({
|
|
86
|
+
import: `import { magicLink } from "better-auth/plugins/magic-link";`,
|
|
87
|
+
call: `magicLink({ sendMagicLink: emailer.auth.sendMagicLink, expiresIn: 60 * 15 })`,
|
|
88
|
+
ring: "auth",
|
|
89
|
+
why: "one tap on the device the mail is open on; signs up too",
|
|
90
|
+
});
|
|
91
|
+
if (has(manifest, "email-otp"))
|
|
92
|
+
lines.push({
|
|
93
|
+
import: `import { emailOTP } from "better-auth/plugins/email-otp";`,
|
|
94
|
+
call: `emailOTP({ sendVerificationOTP: emailer.auth.sendVerificationOTP, otpLength: 6, expiresIn: 60 * 10, allowedAttempts: 3 })`,
|
|
95
|
+
ring: "auth",
|
|
96
|
+
why: "a code, for when the mail is on a different device; three tries",
|
|
97
|
+
});
|
|
98
|
+
if (has(manifest, "passkey"))
|
|
99
|
+
lines.push({
|
|
100
|
+
import: `import { passkey } from "@better-auth/passkey";`,
|
|
101
|
+
call: `passkey({ rpName: "${shop.name}", rpID: domain.host, origin: domain.appUrl })`,
|
|
102
|
+
ring: "auth",
|
|
103
|
+
why: "sign-in only; a passkey is created against an account that exists",
|
|
104
|
+
});
|
|
105
|
+
if (has(manifest, "username"))
|
|
106
|
+
lines.push({ import: `import { username } from "better-auth/plugins/username";`, call: `username()`, ring: "auth", why: "sign in by handle" });
|
|
107
|
+
if (has(manifest, "phone"))
|
|
108
|
+
lines.push({ import: `import { phoneNumber } from "better-auth/plugins/phone-number";`, call: `phoneNumber({ sendOTP: async () => { /* TODO: wire an SMS provider */ } })`, ring: "auth", why: "SMS codes — TODO wire a provider" });
|
|
109
|
+
if (has(manifest, "anonymous"))
|
|
110
|
+
lines.push({ import: `import { anonymous } from "better-auth/plugins/anonymous";`, call: `anonymous()`, ring: "auth", why: "a cart before an address" });
|
|
111
|
+
if (has(manifest, "api-key"))
|
|
112
|
+
lines.push({
|
|
113
|
+
import: `import { apiKey } from "@better-auth/api-key";`,
|
|
114
|
+
call: `apiKey({ enableSessionForAPIKeys: true, requireName: true, enableMetadata: true, rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 120 } })`,
|
|
115
|
+
ring: "auth",
|
|
116
|
+
why: "machine access; the default is 10 requests per DAY and surfaces as a 500",
|
|
117
|
+
});
|
|
118
|
+
if (has(manifest, "two-factor"))
|
|
119
|
+
lines.push({ import: `import { twoFactor } from "better-auth/plugins/two-factor";`, call: `twoFactor()`, ring: "auth", why: "TOTP for operators" });
|
|
120
|
+
if (has(manifest, "organization"))
|
|
121
|
+
lines.push({ import: `import { organization } from "better-auth/plugins/organization";`, call: `organization()`, ring: "auth", why: "teams that share a shop account" });
|
|
122
|
+
if (has(manifest, "multi-session"))
|
|
123
|
+
lines.push({ import: `import { multiSession } from "better-auth/plugins/multi-session";`, call: `multiSession()`, ring: "auth", why: "switch accounts without signing out" });
|
|
124
|
+
if (has(manifest, "jwt"))
|
|
125
|
+
lines.push({ import: `import { jwt } from "better-auth/plugins/jwt";`, call: `jwt()`, ring: "auth", why: "tokens for a service that cannot hold a cookie" });
|
|
126
|
+
if (has(manifest, "bearer"))
|
|
127
|
+
lines.push({ import: `import { bearer } from "better-auth/plugins/bearer";`, call: `bearer()`, ring: "auth", why: "the session token in a header" });
|
|
128
|
+
if (has(manifest, "haveibeenpwned"))
|
|
129
|
+
lines.push({ import: `import { haveIBeenPwned } from "better-auth/plugins/haveibeenpwned";`, call: `haveIBeenPwned()`, ring: "auth", why: "refuse a breached password" });
|
|
130
|
+
if (has(manifest, "one-time-token"))
|
|
131
|
+
lines.push({ import: `import { oneTimeToken } from "better-auth/plugins/one-time-token";`, call: `oneTimeToken()`, ring: "auth", why: "hand a session to another origin once" });
|
|
132
|
+
if (has(manifest, "generic-oauth"))
|
|
133
|
+
lines.push({ import: `import { genericOAuth } from "better-auth/plugins/generic-oauth";`, call: `genericOAuth({ config: [] /* TODO: your provider */ })`, ring: "auth", why: "an identity provider Better Auth does not ship" });
|
|
134
|
+
if (has(manifest, "oauth-proxy"))
|
|
135
|
+
lines.push({ import: `import { oAuthProxy } from "better-auth/plugins/oauth-proxy";`, call: `oAuthProxy()`, ring: "auth", why: "OAuth callbacks on preview URLs" });
|
|
136
|
+
if (has(manifest, "siwe"))
|
|
137
|
+
lines.push({ import: `import { siwe } from "better-auth/plugins/siwe";`, call: `siwe({ domain: domain.host, getNonce: async () => crypto.randomUUID(), verifyMessage: async () => false /* TODO */ })`, ring: "auth", why: "wallet sign-in — TODO verifyMessage" });
|
|
138
|
+
if (has(manifest, "device-authorization"))
|
|
139
|
+
lines.push({ import: `import { deviceAuthorization } from "better-auth/plugins/device-authorization";`, call: `deviceAuthorization()`, ring: "auth", why: "sign in on a TV or CLI" });
|
|
140
|
+
if (has(manifest, "custom-session"))
|
|
141
|
+
lines.push({ import: `import { customSession } from "better-auth/plugins/custom-session";`, call: `customSession(async ({ user, session }) => ({ user, session }))`, ring: "auth", why: "computed fields on every session" });
|
|
142
|
+
// Strict has no array to edit: launch content is content/*.json, snapshotted into lib/content.ts.
|
|
143
|
+
const strict = manifest.layout === "strict";
|
|
144
|
+
if (has(manifest, "blogs"))
|
|
145
|
+
lines.push({
|
|
146
|
+
import: `import { blogs } from "@saastemly/better-blogs";${strict ? `\nimport { posts as launchPosts } from "./lib/content.ts";` : ""}`,
|
|
147
|
+
call: `blogs({ defaultLocale: "${shop.locale}", posts: ${strict ? "launchPosts" : "[]"} })`,
|
|
148
|
+
ring: "auth",
|
|
149
|
+
why: strict ? "launch posts come from content/posts.json" : "launch posts go in the array; an operator edits rows afterwards",
|
|
150
|
+
});
|
|
151
|
+
if (has(manifest, "faqs"))
|
|
152
|
+
lines.push({
|
|
153
|
+
import: `import { faqs } from "@saastemly/better-faqs";${strict ? `\nimport { faqEntries } from "./lib/content.ts";` : ""}`,
|
|
154
|
+
call: `faqs({ defaultLocale: "${shop.locale}", entries: ${strict ? "faqEntries" : "[]"} })`,
|
|
155
|
+
ring: "auth",
|
|
156
|
+
why: strict ? "launch FAQ comes from content/faqs.json" : "launch FAQ goes in the array",
|
|
157
|
+
});
|
|
158
|
+
if (has(manifest, "cloudflare") || has(manifest, "simply"))
|
|
159
|
+
lines.push({
|
|
160
|
+
import: `import { dns } from "@saastemly/better-dns";\nimport { ${has(manifest, "cloudflare") ? "cloudflareDns" : "simplyDns"} } from "@saastemly/better-dns/providers/${has(manifest, "cloudflare") ? "cloudflare" : "simply"}";`,
|
|
161
|
+
call: has(manifest, "cloudflare")
|
|
162
|
+
? `dns({ providers: dnsToken ? { cloudflare: cloudflareDns({ apiToken: dnsToken }) } : {}, zones: dnsToken ? { [domain.zone]: "cloudflare" } : {}, records: dnsToken ? storefrontRecords(domain, read(env, "GITHUB_PAGES_HOST") ?? "") : [] })`
|
|
163
|
+
: `dns({ providers: dnsToken ? { simply: simplyDns({ apiKey: dnsToken }) } : {}, zones: dnsToken ? { [domain.zone]: "simply" } : {}, records: dnsToken ? storefrontRecords(domain, read(env, "GITHUB_PAGES_HOST") ?? "") : [] })`,
|
|
164
|
+
ring: "auth",
|
|
165
|
+
why: "the deploy points the domain; a sync never removes a record it did not declare",
|
|
166
|
+
});
|
|
167
|
+
lines.push({ import: `import { email } from "@saastemly/better-email";`, call: `email({ emailer })`, ring: "auth", why: "a log of every send — the difference between a shrug and the provider's own refusal" });
|
|
168
|
+
if (has(manifest, "admin")) {
|
|
169
|
+
lines.push({ import: `import { apiDashboard } from "@saastemly/better-admin-ui/server";`, call: `apiDashboard()`, ring: "auth", why: "the generated storefront and panel's server half" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── commerce ring ────────────────────────────────────────────────────
|
|
173
|
+
const commerce = (id: string, call: string, why: string, imp?: string) => {
|
|
174
|
+
if (has(manifest, id)) lines.push({ import: imp ?? `import { ${camel(id)} } from "@saastemly/better-commerce/plugins/${id}";`, call, ring: "commerce", why });
|
|
175
|
+
};
|
|
176
|
+
commerce("catalog", `catalog()`, "products as rows; the panel grows Product screens");
|
|
177
|
+
commerce("catalog-import", `catalogImport({ sources: [] })`, "pushed from \`vc import\`; the worker carries no catalogue");
|
|
178
|
+
commerce("metafields", `metafields({ definitions: [] })`, "typed custom data on any row");
|
|
179
|
+
commerce("personalization", `personalization()`, "validated at add-to-cart, priced from rows never from the client");
|
|
180
|
+
commerce("inventory", `inventory()`, "stock and reservations");
|
|
181
|
+
commerce("bundles", `bundles({ bundles: [] })`, "kits at a set price");
|
|
182
|
+
commerce("price-lists", `priceLists()`, "per-group and per-date prices");
|
|
183
|
+
commerce("translations", `translations()`, "copy in more than one language");
|
|
184
|
+
commerce("search", `search({ provider: memorySearchProvider() })`, "memory locally; swap for Meilisearch", `import { search } from "@saastemly/better-commerce/plugins/search";\nimport { memorySearchProvider } from "@saastemly/better-commerce/providers/search";`);
|
|
185
|
+
commerce("regions", `regions({ regions: [{ id: "${shop.country.toLowerCase()}", name: "${shop.country}", currency: "${shop.currency}", countries: ["${shop.country}"], isDefault: true }] })`, "where you sell, in what currency");
|
|
186
|
+
commerce("sales-channels", `salesChannels({ channels: [{ id: "web", name: "Web", isDefault: true }] })`, "scope products per channel");
|
|
187
|
+
commerce("discounts", `discounts()`, "codes and campaigns");
|
|
188
|
+
commerce("gift-cards", `giftCards()`, "balance derived from the ledger", `import { giftCards, giftCardFulfillment } from "@saastemly/better-commerce/plugins/gift-cards";`);
|
|
189
|
+
commerce("loyalty", `loyalty()`, "earn on paid orders");
|
|
190
|
+
commerce("subscriptions", `subscriptions()`, "recurring orders");
|
|
191
|
+
commerce("draft-orders", `draftOrders()`, "an operator builds a cart for a buyer");
|
|
192
|
+
commerce("order-edits", `orderEdits()`, "change a paid order, charge or refund the difference");
|
|
193
|
+
commerce("returns", `returns()`, "the road to a refund");
|
|
194
|
+
commerce("wishlists", `wishlists()`, "prices resolved on read");
|
|
195
|
+
commerce("reviews", `reviews()`, "verified badge derived from a paid order");
|
|
196
|
+
commerce("customer-groups", `customerGroups()`, "segments with their own prices");
|
|
197
|
+
commerce("acp", `acp({ merchantDisplayName: "${shop.name}" })`, "agentic commerce", `import { acp } from "@saastemly/better-commerce/acp";`);
|
|
198
|
+
commerce(
|
|
199
|
+
"fulfillments",
|
|
200
|
+
`fulfillments({ providers: [authFulfillment(), creditsFulfillment()${has(manifest, "gift-cards") ? ", giftCardFulfillment()" : ""}${has(manifest, "business-central") ? `, erpFulfillment("${shop.currency.toUpperCase()}")` : ""}${nftProvider}]${nftDefault} })`,
|
|
201
|
+
"what happens when the money is confirmed",
|
|
202
|
+
`import { authFulfillment, creditsFulfillment, fulfillments, manualFulfillment } from "@saastemly/better-commerce/plugins/fulfillments";${has(manifest, "business-central") ? `\nimport { erpFulfillment } from "./lib/erp.ts";` : ""}`,
|
|
203
|
+
);
|
|
204
|
+
commerce(
|
|
205
|
+
"shipping-zones",
|
|
206
|
+
`shippingZones({ zones: [{ id: "domestic", name: "${shop.country}", countries: ["${shop.country}"], rank: 0 }], shippingOptions: [{ id: "standard", zoneId: "domestic", name: "Standard", priceType: "flat", amount: Number(must(env, "SHIPPING_DOMESTIC_MINOR")), currency: "${shop.currency}", rank: 0 }, { id: "free", zoneId: "domestic", name: "Free", priceType: "free", currency: "${shop.currency}", minSubtotal: Number(must(env, "SHIPPING_FREE_FROM_MINOR")), rank: 1 }] })`,
|
|
207
|
+
"quoted from required env; a shop with no option quotes FREE delivery silently",
|
|
208
|
+
);
|
|
209
|
+
/**
|
|
210
|
+
* Tax is declared only when the shop is REGISTERED to charge it. A
|
|
211
|
+
* business below its registration threshold that adds tax to a price is
|
|
212
|
+
* collecting money it has no right to; one above it that does not is
|
|
213
|
+
* paying the tax out of its own margin. Neither is a default worth
|
|
214
|
+
* guessing, so the manifest answers it.
|
|
215
|
+
*/
|
|
216
|
+
const registered = shop.taxRegistered === true;
|
|
217
|
+
const askedLive = has(manifest, "stripe-tax");
|
|
218
|
+
commerce(
|
|
219
|
+
"tax-regions",
|
|
220
|
+
askedLive
|
|
221
|
+
? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }], attachTax: false })`
|
|
222
|
+
: !registered
|
|
223
|
+
? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] })`
|
|
224
|
+
: vat
|
|
225
|
+
? `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [{ name: "${vat.name}", code: "vat", rate: ${vat.rate} }] }] })`
|
|
226
|
+
: `taxRegions({ regions: [{ countryCode: "${shop.country}", name: "${shop.country}", rates: [] }] /* TODO VERIFY: no standard rate known for ${shop.country} */ })`,
|
|
227
|
+
askedLive
|
|
228
|
+
? "Stripe is asked per basket, so an unregistered shop charges nothing and a registered one charges the buyer's own rate — with nothing here to keep up to date"
|
|
229
|
+
: !registered
|
|
230
|
+
? `NOT registered for ${vat?.name ?? "VAT"}: no tax is added, because a business below the threshold must not charge it. Set taxRegistered in voidcommerce.json when you register.`
|
|
231
|
+
: vat
|
|
232
|
+
? `${vat.rate / 100}% ${vat.name} declared in code, so a fresh deploy is never tax-free`
|
|
233
|
+
: "TODO VERIFY: registered, but no standard rate is known for this country — declare it",
|
|
234
|
+
);
|
|
235
|
+
// Import-only: the calculator is an argument to taxRegions(), not a plugin.
|
|
236
|
+
if (askedLive)
|
|
237
|
+
lines.push({
|
|
238
|
+
import: `import { taxCalculator } from "@saastemly/better-commerce/providers/tax";\nimport { stripeTax } from "@saastemly/better-commerce/providers/stripe-tax";`,
|
|
239
|
+
call: "",
|
|
240
|
+
ring: "commerce",
|
|
241
|
+
why: "",
|
|
242
|
+
});
|
|
243
|
+
commerce("notifications", `notifications({ provider: emailNotifications(emailer) })`, "order confirmations, queued and retried", `import { notifications } from "@saastemly/better-commerce/plugins/notifications";\nimport { emailNotifications } from "./lib/notifications.ts";`);
|
|
244
|
+
commerce("events", `events()`, "webhooks with retries");
|
|
245
|
+
commerce("workflows", `workflows()`, "multi-step operations that survive a crash");
|
|
246
|
+
commerce("files", `files({ provider: memoryFileProvider() })`, "uploads; swap for R2", `import { files } from "@saastemly/better-commerce/plugins/files";\nimport { memoryFileProvider } from "@saastemly/better-commerce/providers/file";`);
|
|
247
|
+
commerce("merchants", `merchants()`, "several sellers, with payouts");
|
|
248
|
+
if (has(manifest, "nft"))
|
|
249
|
+
lines.push({
|
|
250
|
+
import: `import { nft, nftOrPhysical } from "@saastemly/better-commerce/plugins/nft";\nimport { mintProvider } from "./lib/mint.ts";`,
|
|
251
|
+
call: `nft({ blurb: "Rather not have the object? Take an authentic, completely useless token of it instead. Nothing gets printed, nothing gets posted." })`,
|
|
252
|
+
ring: "commerce",
|
|
253
|
+
why: "the buyer chooses a parcel or a token; the catalogue is the same either way",
|
|
254
|
+
});
|
|
255
|
+
commerce("store", `store({ name: "${shop.name}", settings: { "support.email": { value: "info@${shop.domain}", public: true } } })`, "public settings the storefront reads");
|
|
256
|
+
commerce("runtime", `runtime({ cache: memoryCacheProvider(), lock: databaseLockProvider })`, "cache and locks", `import { runtime } from "@saastemly/better-commerce/plugins/runtime";\nimport { memoryCacheProvider } from "@saastemly/better-commerce/providers/cache";\nimport { databaseLockProvider } from "@saastemly/better-commerce/providers/locking";`);
|
|
257
|
+
|
|
258
|
+
const imports = [...new Set(lines.flatMap((line) => (line.import ? line.import.split("\n") : [])))].sort();
|
|
259
|
+
const render = (ring: Line["ring"]) =>
|
|
260
|
+
lines
|
|
261
|
+
// A line may carry only an import — the tax calculator is a value the
|
|
262
|
+
// plugin takes, not a plugin of its own.
|
|
263
|
+
.filter((line) => line.ring === ring && line.call !== "")
|
|
264
|
+
.map((line) => ` // ${line.why}\n ${line.call},`)
|
|
265
|
+
.join("\n");
|
|
266
|
+
|
|
267
|
+
const rail = has(manifest, "adyen") ? "adyen" : "stripe";
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Mail. Cloudflare's binding is the one transport with no credential at
|
|
271
|
+
* all — the Worker's own account is the authorisation — so where it is
|
|
272
|
+
* chosen there is nothing to put in a secret and nothing to rotate.
|
|
273
|
+
* Neither provider exists locally, so MailDev stands in for both.
|
|
274
|
+
*/
|
|
275
|
+
// A shop with no DNS provider has no token to read, and naming one that is
|
|
276
|
+
// not in env.ts is a lie a reader has to disprove.
|
|
277
|
+
const dnsProvider = has(manifest, "cloudflare") ? "CLOUDFLARE_DNS_TOKEN" : has(manifest, "simply") ? "SIMPLY_API_KEY" : null;
|
|
278
|
+
const dnsDeclaration = dnsProvider ? ` const dnsToken = read(env, "${dnsProvider}");` : "";
|
|
279
|
+
const cloudflareMail = has(manifest, "cloudflare-email");
|
|
280
|
+
const mailProvider = cloudflareMail ? "Cloudflare" : "Resend";
|
|
281
|
+
const mailImport = cloudflareMail
|
|
282
|
+
? `import { cloudflareTransport } from "@saastemly/better-email/transports/cloudflare";`
|
|
283
|
+
: `import { resendTransport } from "@saastemly/better-email/transports/resend";`;
|
|
284
|
+
const mailBinding = cloudflareMail
|
|
285
|
+
? `// The Worker's own send_email binding. Read late: bindings are not always
|
|
286
|
+
// readable at module scope, and locally there is none at all.
|
|
287
|
+
const mail = (env as Record<string, unknown>)["EMAIL"] as Parameters<typeof cloudflareTransport>[0]["binding"];`
|
|
288
|
+
: `const resendKey = read(env, "RESEND_API_KEY");`;
|
|
289
|
+
const mailTransport = cloudflareMail
|
|
290
|
+
? `mail ? cloudflareTransport({ binding: mail }) : smtpTransport()`
|
|
291
|
+
: `resendKey ? resendTransport({ apiKey: resendKey }) : smtpTransport()`;
|
|
292
|
+
const mailTransportId = cloudflareMail ? `mail ? "cloudflare" : "maildev"` : `resendKey ? "resend" : "maildev"`;
|
|
293
|
+
const socials = [social(manifest, "google", "GOOGLE"), social(manifest, "apple", "APPLE"), social(manifest, "github", "GITHUB")].filter(Boolean);
|
|
294
|
+
|
|
295
|
+
return `// GENERATED by \`vc init\` from voidcommerce.json. Edit the manifest and
|
|
296
|
+
// run \`vc init\` again; hand edits between the markers are overwritten.
|
|
297
|
+
import { commerce } from "@saastemly/better-commerce";
|
|
298
|
+
import { defineAuth } from "void/auth";
|
|
299
|
+
import { createEmailer, logSends } from "@saastemly/better-email";
|
|
300
|
+
import { getCurrentAuthContext } from "@better-auth/core/context";
|
|
301
|
+
${mailImport}
|
|
302
|
+
import { smtpTransport } from "@saastemly/better-email/transports/smtp";
|
|
303
|
+
import { paymentProvider } from "./lib/payment.ts";
|
|
304
|
+
import { shopDomain, storefrontRecords } from "./lib/domain.ts";
|
|
305
|
+
${imports.join("\n")}
|
|
306
|
+
|
|
307
|
+
type ConfigEnv = Record<string, unknown>;
|
|
308
|
+
|
|
309
|
+
/** \`unset\` is the one documented value that reads as absent. Preflight refuses it. */
|
|
310
|
+
const UNSET = "unset";
|
|
311
|
+
const read = (env: ConfigEnv, key: string): string | undefined => {
|
|
312
|
+
const value = env[key];
|
|
313
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
314
|
+
return value === UNSET ? undefined : value;
|
|
315
|
+
};
|
|
316
|
+
const must = (env: ConfigEnv, key: string): string => read(env, key) ?? "";
|
|
317
|
+
|
|
318
|
+
export default defineAuth(({ defaults, env }) => {
|
|
319
|
+
const domain = shopDomain(must(env, "SHOP_DOMAIN") || "localhost.test", read(env, "SHOP_ZONE"));
|
|
320
|
+
${mailBinding}
|
|
321
|
+
const cronSecret = read(env, "COMMERCE_CRON_SECRET");
|
|
322
|
+
${dnsDeclaration}
|
|
323
|
+
|
|
324
|
+
/** One transport for every email; MailDev locally, ${mailProvider} in production. */
|
|
325
|
+
const emailer = createEmailer({
|
|
326
|
+
appName: "${shop.name}",
|
|
327
|
+
from: read(env, "EMAIL_FROM") ?? "${shop.name} <noreply@${shop.domain}>",
|
|
328
|
+
locale: "${shop.locale.slice(0, 2)}" as never,
|
|
329
|
+
transport: ${mailTransport},
|
|
330
|
+
onSent: logSends(async () => {
|
|
331
|
+
try {
|
|
332
|
+
const endpoint = (await getCurrentAuthContext()) as unknown as { context?: { adapter?: unknown } };
|
|
333
|
+
return (endpoint.context?.adapter ?? null) as never;
|
|
334
|
+
} catch {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
}, { transportId: ${mailTransportId} }),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
...defaults,
|
|
342
|
+
// Better Auth defaults this to NODE_ENV=production, which a Worker never sets.
|
|
343
|
+
rateLimit: { enabled: true, storage: "database" },
|
|
344
|
+
// Explicit, because the default is off and a version bump could flip it.
|
|
345
|
+
emailAndPassword: { enabled: ${has(manifest, "password")} },
|
|
346
|
+
socialProviders: {
|
|
347
|
+
${socials.join("\n")}
|
|
348
|
+
},
|
|
349
|
+
account: { accountLinking: { enabled: true, trustedProviders: [${has(manifest, "google") ? '"google"' : ""}${has(manifest, "apple") ? ', "apple"' : ""}] } },
|
|
350
|
+
emailVerification: { sendVerificationEmail: emailer.auth.sendVerificationEmail, sendOnSignUp: false },
|
|
351
|
+
trustedOrigins: [...(Array.isArray(defaults.trustedOrigins) ? defaults.trustedOrigins : []), ...domain.frontendOrigins],
|
|
352
|
+
advanced: {
|
|
353
|
+
...defaults.advanced,
|
|
354
|
+
...(domain.frontendOrigins.length > 0 ? { defaultCookieAttributes: { sameSite: "none" as const, secure: true } } : {}),
|
|
355
|
+
},
|
|
356
|
+
plugins: [
|
|
357
|
+
...(Array.isArray(defaults.plugins) ? defaults.plugins : []),
|
|
358
|
+
// ── vc:auth ─────────────────────────────────────────────────
|
|
359
|
+
${render("auth")}
|
|
360
|
+
// ── /vc:auth ────────────────────────────────────────────────
|
|
361
|
+
commerce({
|
|
362
|
+
provider: paymentProvider, // ${rail}
|
|
363
|
+
defaultCurrency: () => "${shop.currency}",${
|
|
364
|
+
askedLive
|
|
365
|
+
? `
|
|
366
|
+
// The rate is asked for per basket, so there is none to declare:
|
|
367
|
+
// no registration means no tax, and a registration means the
|
|
368
|
+
// buyer's own rate. taxRegions() keeps its tables and admin
|
|
369
|
+
// routes but does not claim this slot.
|
|
370
|
+
pricing: { tax: taxCalculator(stripeTax({ secretKey: must(env, "STRIPE_SECRET_KEY") })) },`
|
|
371
|
+
: ""
|
|
372
|
+
}
|
|
373
|
+
...(cronSecret ? { cron: { secret: cronSecret } } : {}),
|
|
374
|
+
plugins: [
|
|
375
|
+
// ── vc:commerce ─────────────────────────────────────
|
|
376
|
+
${render("commerce").replace(/^/gm, "\t")}
|
|
377
|
+
// ── /vc:commerce ────────────────────────────────────
|
|
378
|
+
],
|
|
379
|
+
}),
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const camel = (id: string) => id.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|