@hogsend/cli 0.10.0 → 0.12.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/dist/bin.js +13517 -1754
- package/dist/bin.js.map +1 -1
- package/package.json +6 -3
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +5 -4
- package/skills/hogsend-cli/SKILL.md +32 -1
- package/skills/hogsend-extending/SKILL.md +4 -1
- package/skills/hogsend-extending/references/swap-a-provider.md +273 -51
- package/skills/hogsend-integrate/SKILL.md +198 -0
- package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
- package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
- package/skills/hogsend-integrate/references/verification.md +86 -0
- package/skills/hogsend-migrate/SKILL.md +147 -0
- package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
- package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
- package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
- package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
- package/src/__tests__/admin-recovery.test.ts +193 -0
- package/src/__tests__/dev.test.ts +323 -0
- package/src/__tests__/dns-apply.test.ts +297 -0
- package/src/__tests__/dns.test.ts +143 -0
- package/src/__tests__/domain-command.test.ts +216 -0
- package/src/__tests__/proc.test.ts +177 -0
- package/src/__tests__/setup-steps.test.ts +363 -0
- package/src/bin.ts +13 -3
- package/src/commands/dev.ts +444 -0
- package/src/commands/domain.ts +437 -0
- package/src/commands/events.ts +4 -1
- package/src/commands/index.ts +4 -0
- package/src/commands/setup.ts +34 -163
- package/src/commands/studio-admin.ts +340 -0
- package/src/commands/studio.ts +17 -1
- package/src/lib/admin-recovery.ts +193 -0
- package/src/lib/dns-apply.ts +218 -0
- package/src/lib/dns.ts +217 -0
- package/src/lib/proc.ts +189 -0
- package/src/lib/setup-steps.ts +333 -0
- package/studio/assets/index-CSXAjTbe.js +265 -0
- package/studio/assets/index-DCsT0fnT.css +1 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-BNDE5JtQ.css +0 -1
- package/studio/assets/index-CgJBk-Ft.js +0 -250
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { password as passwordPrompt, text } from "@clack/prompts";
|
|
3
|
+
import {
|
|
4
|
+
type AdminRecovery,
|
|
5
|
+
AdminRecoveryConfigError,
|
|
6
|
+
type AdminSummary,
|
|
7
|
+
createAdminRecovery,
|
|
8
|
+
} from "../lib/admin-recovery.js";
|
|
9
|
+
import { color } from "../lib/output.js";
|
|
10
|
+
import { bail } from "../lib/prompt.js";
|
|
11
|
+
import type { CommandContext } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `hogsend studio admin <create|reset|list>` — the shell-gated Studio admin
|
|
15
|
+
* recovery primitive. Routed here from the `studio` command's subcommand
|
|
16
|
+
* dispatch.
|
|
17
|
+
*
|
|
18
|
+
* Security posture (see lib/admin-recovery.ts for the invariants):
|
|
19
|
+
* - Gated by holding both DATABASE_URL and BETTER_AUTH_SECRET. No HTTP.
|
|
20
|
+
* - Passwords go ONLY through better-auth's server API (scrypt). No raw SQL.
|
|
21
|
+
* - The password is NEVER echoed (masked prompt) and NEVER logged. When the
|
|
22
|
+
* `--password` flag is used we warn (interactively) about shell history.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const adminUsage = `hogsend studio admin <command> [options]
|
|
26
|
+
|
|
27
|
+
Shell-gated Studio admin recovery (PostHog/GitLab/Rails-style). Constructs a
|
|
28
|
+
better-auth instance directly against your database and uses better-auth's
|
|
29
|
+
server API so password hashing is identical to the running app. NO HTTP and no
|
|
30
|
+
running API are required — this is gated by holding the DB URL and app secret.
|
|
31
|
+
|
|
32
|
+
DATABASE_URL and BETTER_AUTH_SECRET are read from the ENVIRONMENT only (not from
|
|
33
|
+
a .env file). Run with your app env loaded, e.g.
|
|
34
|
+
dotenvx run -- hogsend studio admin create
|
|
35
|
+
railway run hogsend studio admin create
|
|
36
|
+
pnpm studio:admin # the scaffold's env-loaded wrapper
|
|
37
|
+
|
|
38
|
+
Commands:
|
|
39
|
+
${color.cyan("create")} Create a Studio admin user (the first admin, or another).
|
|
40
|
+
${color.cyan("reset")} Set a new password for an existing admin (by email).
|
|
41
|
+
${color.cyan("list")} List existing admins (id, email, name, createdAt). No secrets.
|
|
42
|
+
|
|
43
|
+
Options:
|
|
44
|
+
--email <e> Admin email (required; prompted in a TTY if omitted).
|
|
45
|
+
--name <n> Display name for create (defaults to the email local-part).
|
|
46
|
+
--password <p> Password for create/reset. PREFER the masked prompt — a
|
|
47
|
+
value passed here can leak into your shell history.
|
|
48
|
+
--no-revoke (reset) Keep existing sessions instead of revoking them.
|
|
49
|
+
--database-url <u> Override DATABASE_URL (else read from the environment).
|
|
50
|
+
--json Emit a single JSON result document (non-interactive).
|
|
51
|
+
-h, --help Show this help.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
hogsend studio admin create --email admin@example.com
|
|
55
|
+
hogsend studio admin reset --email admin@example.com
|
|
56
|
+
hogsend studio admin list --json
|
|
57
|
+
|
|
58
|
+
Security: passwords are written ONLY via better-auth (scrypt) — never raw SQL,
|
|
59
|
+
never plaintext at rest, never logged. Prefer the masked prompt over --password.`;
|
|
60
|
+
|
|
61
|
+
interface AdminFlags {
|
|
62
|
+
email?: string;
|
|
63
|
+
name?: string;
|
|
64
|
+
password?: string;
|
|
65
|
+
revoke: boolean;
|
|
66
|
+
databaseUrl?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse the admin subcommand argv (after the `admin <sub>` tokens). */
|
|
70
|
+
function parseAdminFlags(argv: string[]): AdminFlags {
|
|
71
|
+
const { values } = parseArgs({
|
|
72
|
+
args: argv,
|
|
73
|
+
allowPositionals: true,
|
|
74
|
+
strict: false,
|
|
75
|
+
options: {
|
|
76
|
+
email: { type: "string" },
|
|
77
|
+
name: { type: "string" },
|
|
78
|
+
password: { type: "string" },
|
|
79
|
+
revoke: { type: "boolean", default: true },
|
|
80
|
+
"database-url": { type: "string" },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
email: typeof values.email === "string" ? values.email : undefined,
|
|
85
|
+
name: typeof values.name === "string" ? values.name : undefined,
|
|
86
|
+
password: typeof values.password === "string" ? values.password : undefined,
|
|
87
|
+
revoke: values.revoke !== false,
|
|
88
|
+
databaseUrl:
|
|
89
|
+
typeof values["database-url"] === "string"
|
|
90
|
+
? values["database-url"]
|
|
91
|
+
: undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve the gating env (DATABASE_URL + BETTER_AUTH_SECRET) from flags then
|
|
97
|
+
* `process.env` ONLY — there is no cwd `.env` read here (consistent with
|
|
98
|
+
* `db:migrate`, which also reads the environment directly). Holding both IS the
|
|
99
|
+
* gate; there is no HTTP fallback. Fails fast and clearly if either is missing,
|
|
100
|
+
* telling the operator how to run the command with its env loaded.
|
|
101
|
+
*/
|
|
102
|
+
function resolveGatingEnv(
|
|
103
|
+
ctx: CommandContext,
|
|
104
|
+
flags: AdminFlags,
|
|
105
|
+
): { databaseUrl: string; secret: string; baseURL: string | undefined } {
|
|
106
|
+
const databaseUrl = flags.databaseUrl ?? process.env.DATABASE_URL;
|
|
107
|
+
const secret = process.env.BETTER_AUTH_SECRET;
|
|
108
|
+
const baseURL = process.env.BETTER_AUTH_URL ?? process.env.API_PUBLIC_URL;
|
|
109
|
+
|
|
110
|
+
const missing: string[] = [];
|
|
111
|
+
if (!databaseUrl) missing.push("DATABASE_URL");
|
|
112
|
+
if (!secret) missing.push("BETTER_AUTH_SECRET");
|
|
113
|
+
if (missing.length > 0) {
|
|
114
|
+
ctx.out.fail(
|
|
115
|
+
`${missing.join(" and ")} ${missing.length > 1 ? "are" : "is"} ` +
|
|
116
|
+
"required, and are read from the environment only (not a .env file). " +
|
|
117
|
+
"Run this command with your app env loaded, e.g.\n" +
|
|
118
|
+
" export DATABASE_URL=… BETTER_AUTH_SECRET=…\n" +
|
|
119
|
+
" dotenvx run -- hogsend studio admin create\n" +
|
|
120
|
+
" railway run hogsend studio admin create\n" +
|
|
121
|
+
" pnpm studio:admin # the scaffold's env-loaded wrapper\n" +
|
|
122
|
+
"This command is gated by DB + secret access (no HTTP fallback).",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Non-null assertions are safe: ctx.out.fail above exits the process.
|
|
127
|
+
return {
|
|
128
|
+
databaseUrl: databaseUrl as string,
|
|
129
|
+
secret: secret as string,
|
|
130
|
+
baseURL,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Prompt for an email in a TTY, or fail with guidance otherwise. */
|
|
135
|
+
async function resolveEmail(
|
|
136
|
+
ctx: CommandContext,
|
|
137
|
+
flags: AdminFlags,
|
|
138
|
+
defaultEmail?: string,
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
if (flags.email && flags.email.length > 0) return flags.email;
|
|
141
|
+
if (!ctx.out.interactive) {
|
|
142
|
+
ctx.out.fail("--email is required (no TTY to prompt).");
|
|
143
|
+
}
|
|
144
|
+
const value = bail(
|
|
145
|
+
await text({
|
|
146
|
+
message: "Admin email",
|
|
147
|
+
placeholder: defaultEmail ?? "admin@example.com",
|
|
148
|
+
initialValue: defaultEmail,
|
|
149
|
+
validate: (v) =>
|
|
150
|
+
v?.includes("@") ? undefined : "Enter a valid email address.",
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
return value.trim();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a password: from the flag if provided (with an interactive shell-
|
|
158
|
+
* history warning), otherwise via a masked prompt typed twice (confirm).
|
|
159
|
+
* NEVER echoed, NEVER logged.
|
|
160
|
+
*/
|
|
161
|
+
async function resolvePassword(
|
|
162
|
+
ctx: CommandContext,
|
|
163
|
+
flags: AdminFlags,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
if (flags.password && flags.password.length > 0) {
|
|
166
|
+
if (ctx.out.interactive) {
|
|
167
|
+
ctx.out.log(
|
|
168
|
+
color.yellow(
|
|
169
|
+
"warning: --password can leak into your shell history; " +
|
|
170
|
+
"prefer the masked prompt next time.",
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return flags.password;
|
|
175
|
+
}
|
|
176
|
+
if (!ctx.out.interactive) {
|
|
177
|
+
ctx.out.fail(
|
|
178
|
+
"--password is required (no TTY for the masked prompt). " +
|
|
179
|
+
"Note: a value passed via --password may leak into shell history.",
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const first = bail(
|
|
183
|
+
await passwordPrompt({
|
|
184
|
+
message: "New password (min 8 chars)",
|
|
185
|
+
validate: (v) =>
|
|
186
|
+
v && v.length >= 8 ? undefined : "Password must be at least 8 chars.",
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const second = bail(
|
|
190
|
+
await passwordPrompt({
|
|
191
|
+
message: "Confirm password",
|
|
192
|
+
validate: (v) => (v === first ? undefined : "Passwords do not match."),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
if (first !== second) {
|
|
196
|
+
ctx.out.fail("Passwords do not match.");
|
|
197
|
+
}
|
|
198
|
+
return first;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Render an admin summary for human output (no secrets present in the type). */
|
|
202
|
+
function printAdmin(ctx: CommandContext, action: string, admin: AdminSummary) {
|
|
203
|
+
ctx.out.kv(
|
|
204
|
+
{
|
|
205
|
+
id: admin.id,
|
|
206
|
+
email: admin.email,
|
|
207
|
+
name: admin.name,
|
|
208
|
+
createdAt: admin.createdAt,
|
|
209
|
+
},
|
|
210
|
+
`${action} admin`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runCreate(
|
|
215
|
+
ctx: CommandContext,
|
|
216
|
+
flags: AdminFlags,
|
|
217
|
+
recovery: AdminRecovery,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const email = await resolveEmail(ctx, flags);
|
|
220
|
+
const passwordValue = await resolvePassword(ctx, flags);
|
|
221
|
+
const admin = await recovery.create({
|
|
222
|
+
email,
|
|
223
|
+
password: passwordValue,
|
|
224
|
+
name: flags.name,
|
|
225
|
+
});
|
|
226
|
+
if (ctx.json) {
|
|
227
|
+
ctx.out.json({ action: "create", admin });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
printAdmin(ctx, "Created", admin);
|
|
231
|
+
ctx.out.outro(`${color.green("✓")} Admin created. You can now sign in.`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function runReset(
|
|
235
|
+
ctx: CommandContext,
|
|
236
|
+
flags: AdminFlags,
|
|
237
|
+
recovery: AdminRecovery,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
// If exactly one admin exists and --email is omitted in a TTY, offer it.
|
|
240
|
+
let defaultEmail: string | undefined;
|
|
241
|
+
if (!flags.email && ctx.out.interactive) {
|
|
242
|
+
const admins = await recovery.list();
|
|
243
|
+
if (admins.length === 1) defaultEmail = admins[0]?.email;
|
|
244
|
+
}
|
|
245
|
+
const email = await resolveEmail(ctx, flags, defaultEmail);
|
|
246
|
+
const passwordValue = await resolvePassword(ctx, flags);
|
|
247
|
+
const admin = await recovery.reset({
|
|
248
|
+
email,
|
|
249
|
+
password: passwordValue,
|
|
250
|
+
revokeSessions: flags.revoke,
|
|
251
|
+
});
|
|
252
|
+
if (ctx.json) {
|
|
253
|
+
ctx.out.json({ action: "reset", admin, revokedSessions: flags.revoke });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
printAdmin(ctx, "Reset password for", admin);
|
|
257
|
+
ctx.out.outro(
|
|
258
|
+
`${color.green("✓")} Password reset.` +
|
|
259
|
+
(flags.revoke ? " Existing sessions were revoked." : ""),
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function runList(
|
|
264
|
+
ctx: CommandContext,
|
|
265
|
+
recovery: AdminRecovery,
|
|
266
|
+
): Promise<void> {
|
|
267
|
+
const admins = await recovery.list();
|
|
268
|
+
if (ctx.json) {
|
|
269
|
+
ctx.out.json(admins);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (admins.length === 0) {
|
|
273
|
+
ctx.out.note(
|
|
274
|
+
"No admins exist yet. Create one with `hogsend studio admin create`.",
|
|
275
|
+
"Admins",
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
ctx.out.table(
|
|
280
|
+
admins.map((a) => ({ ...a })),
|
|
281
|
+
["id", "email", "name", "createdAt"],
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Entry point for `hogsend studio admin ...`. `argv` is everything AFTER the
|
|
287
|
+
* `admin` token (i.e. the subcommand + its flags). Routed from `studio.ts`.
|
|
288
|
+
*/
|
|
289
|
+
export async function runStudioAdmin(
|
|
290
|
+
ctx: CommandContext,
|
|
291
|
+
argv: string[],
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
const [sub, ...rest] = argv;
|
|
294
|
+
|
|
295
|
+
if (!sub || sub === "-h" || sub === "--help") {
|
|
296
|
+
ctx.out.log(adminUsage);
|
|
297
|
+
if (!sub) return;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!["create", "reset", "list"].includes(sub)) {
|
|
302
|
+
ctx.out.log(adminUsage);
|
|
303
|
+
ctx.out.fail(`unknown subcommand "${sub}"`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Per-subcommand --help.
|
|
307
|
+
if (rest.includes("-h") || rest.includes("--help")) {
|
|
308
|
+
ctx.out.log(adminUsage);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const flags = parseAdminFlags(rest);
|
|
313
|
+
const env = resolveGatingEnv(ctx, flags);
|
|
314
|
+
|
|
315
|
+
let recovery: AdminRecovery;
|
|
316
|
+
try {
|
|
317
|
+
recovery = createAdminRecovery({
|
|
318
|
+
databaseUrl: env.databaseUrl,
|
|
319
|
+
secret: env.secret,
|
|
320
|
+
baseURL: env.baseURL,
|
|
321
|
+
});
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (err instanceof AdminRecoveryConfigError) {
|
|
324
|
+
ctx.out.fail(err.message);
|
|
325
|
+
}
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
if (sub === "create") {
|
|
331
|
+
await runCreate(ctx, flags, recovery);
|
|
332
|
+
} else if (sub === "reset") {
|
|
333
|
+
await runReset(ctx, flags, recovery);
|
|
334
|
+
} else {
|
|
335
|
+
await runList(ctx, recovery);
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
await recovery.close();
|
|
339
|
+
}
|
|
340
|
+
}
|
package/src/commands/studio.ts
CHANGED
|
@@ -5,10 +5,15 @@ import { extname, join, normalize, resolve, sep } from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
import { color } from "../lib/output.js";
|
|
8
|
+
import { runStudioAdmin } from "./studio-admin.js";
|
|
8
9
|
import type { Command, CommandContext } from "./types.js";
|
|
9
10
|
|
|
10
11
|
const usage = `hogsend studio [options]
|
|
11
12
|
|
|
13
|
+
Subcommands:
|
|
14
|
+
admin create | reset | list Shell-gated Studio admin recovery (DB + secret).
|
|
15
|
+
Run \`hogsend studio admin --help\` for details.
|
|
16
|
+
|
|
12
17
|
Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
|
|
13
18
|
browser. The Studio is a static single-page app; this command starts a tiny
|
|
14
19
|
local web server for it on a port of your choosing.
|
|
@@ -126,6 +131,14 @@ function openBrowser(url: string): void {
|
|
|
126
131
|
}
|
|
127
132
|
|
|
128
133
|
async function run(ctx: CommandContext): Promise<void> {
|
|
134
|
+
// Subcommand dispatch: `hogsend studio admin <create|reset|list> ...`.
|
|
135
|
+
// Route before the static-server flag parsing so the admin flags are owned
|
|
136
|
+
// by the admin handler, not the server.
|
|
137
|
+
if (ctx.argv[0] === "admin") {
|
|
138
|
+
await runStudioAdmin(ctx, ctx.argv.slice(1));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
129
142
|
const { values, positionals } = parseArgs({
|
|
130
143
|
args: ctx.argv,
|
|
131
144
|
allowPositionals: true,
|
|
@@ -232,7 +245,10 @@ async function run(ctx: CommandContext): Promise<void> {
|
|
|
232
245
|
"<instance>, or open <instance>/studio directly.",
|
|
233
246
|
),
|
|
234
247
|
"",
|
|
235
|
-
color.dim(
|
|
248
|
+
color.dim(
|
|
249
|
+
"No admin yet? First load shows a read-only info screen pointing to " +
|
|
250
|
+
"'hogsend studio admin create' — there is no web sign-up.",
|
|
251
|
+
),
|
|
236
252
|
color.dim("Press Ctrl+C to stop."),
|
|
237
253
|
].join("\n"),
|
|
238
254
|
"Studio",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createDatabase, user } from "@hogsend/db";
|
|
2
|
+
// Narrow subpath imports: `@hogsend/engine/auth` re-exports only `createAuth`
|
|
3
|
+
// (lib/auth.ts) and `@hogsend/engine/create-admin` only `createAdminUser`
|
|
4
|
+
// (lib/create-admin.ts) — both module graphs touch just better-auth +
|
|
5
|
+
// @hogsend/db. Importing from the engine barrel (`@hogsend/engine`) would
|
|
6
|
+
// eagerly run the env validation in env.ts (requires BETTER_AUTH_SECRET /
|
|
7
|
+
// HATCHET_CLIENT_TOKEN at module-eval time) and pull Hatchet/Resend/PostHog —
|
|
8
|
+
// heavy and wrong here.
|
|
9
|
+
import { createAuth } from "@hogsend/engine/auth";
|
|
10
|
+
import {
|
|
11
|
+
AdminAlreadyExistsError,
|
|
12
|
+
createAdminUser,
|
|
13
|
+
} from "@hogsend/engine/create-admin";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Shell-gated Studio admin recovery primitive (PostHog/GitLab/Rails-style
|
|
17
|
+
* management command). Constructs its own better-auth instance against the DB
|
|
18
|
+
* and uses better-auth's SERVER API so password hashing is identical to the
|
|
19
|
+
* running app. NO HTTP, no running API required.
|
|
20
|
+
*
|
|
21
|
+
* Security invariants (these are acceptance gates, not preferences):
|
|
22
|
+
* - Every password write goes through better-auth's server API (scrypt via
|
|
23
|
+
* `ctx.password.hash` + the internal adapter). Public sign-up is closed
|
|
24
|
+
* (`disableSignUp`), so create() uses the internal adapter too, NOT
|
|
25
|
+
* `auth.api.signUpEmail`. There are NO raw SQL password writes here, ever.
|
|
26
|
+
* - Passwords are never logged and never returned in any result object.
|
|
27
|
+
* - `list` selects only non-secret columns (id/email/name/createdAt) — never
|
|
28
|
+
* the account password/hash.
|
|
29
|
+
* - Gated by holding both `DATABASE_URL` and `BETTER_AUTH_SECRET` (i.e. DB
|
|
30
|
+
* reach + the app secret). There is no HTTP fallback.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A single admin row, as surfaced by `list` (no secrets). */
|
|
34
|
+
export interface AdminSummary {
|
|
35
|
+
id: string;
|
|
36
|
+
email: string;
|
|
37
|
+
name: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AdminRecovery {
|
|
42
|
+
/**
|
|
43
|
+
* Create a new admin user via better-auth's INTERNAL ADAPTER (scrypt-hashes,
|
|
44
|
+
* writes the `user` + `account` rows). NOT via public sign-up — that is now
|
|
45
|
+
* blocked by `disableSignUp`; the internal-adapter path is not subject to that
|
|
46
|
+
* guard and is correct for the trusted CLI. Throws a clear, non-secret error
|
|
47
|
+
* if the email already exists (points at `reset`).
|
|
48
|
+
*/
|
|
49
|
+
create(input: {
|
|
50
|
+
email: string;
|
|
51
|
+
password: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
}): Promise<AdminSummary>;
|
|
54
|
+
/**
|
|
55
|
+
* Set the password for an existing admin. Mirrors better-auth's own
|
|
56
|
+
* `resetPassword` route: hash via `ctx.password.hash`, then either
|
|
57
|
+
* `updatePassword` (if a credential account exists) or `createAccount` a
|
|
58
|
+
* credential account. Optionally revokes existing sessions so an old leaked
|
|
59
|
+
* session cannot survive a recovery reset. Throws if no user matches.
|
|
60
|
+
*/
|
|
61
|
+
reset(input: {
|
|
62
|
+
email: string;
|
|
63
|
+
password: string;
|
|
64
|
+
revokeSessions?: boolean;
|
|
65
|
+
}): Promise<AdminSummary>;
|
|
66
|
+
/** List existing admins (no secret columns selected, ever). */
|
|
67
|
+
list(): Promise<AdminSummary[]>;
|
|
68
|
+
/** Close the pg pool so the CLI process exits cleanly. */
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Thrown when required env (DB URL / app secret) is missing. */
|
|
73
|
+
export class AdminRecoveryConfigError extends Error {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a single admin row from a better-auth `User`-shaped object into the
|
|
77
|
+
* non-secret summary shape.
|
|
78
|
+
*/
|
|
79
|
+
function toSummary(u: {
|
|
80
|
+
id: string;
|
|
81
|
+
email: string;
|
|
82
|
+
name: string;
|
|
83
|
+
createdAt: Date | string;
|
|
84
|
+
}): AdminSummary {
|
|
85
|
+
const created =
|
|
86
|
+
u.createdAt instanceof Date ? u.createdAt.toISOString() : u.createdAt;
|
|
87
|
+
return { id: u.id, email: u.email, name: u.name, createdAt: created };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build an {@link AdminRecovery} bound to a DB + app secret. Constructs a
|
|
92
|
+
* minimal better-auth instance directly (NOT `createHogsendClient`, which boots
|
|
93
|
+
* Hatchet/Resend/PostHog and is heavy + irrelevant here).
|
|
94
|
+
*
|
|
95
|
+
* `baseURL` is only used by better-auth for cookie/URL config and is irrelevant
|
|
96
|
+
* to these headless server calls; it defaults to localhost.
|
|
97
|
+
*/
|
|
98
|
+
export function createAdminRecovery(opts: {
|
|
99
|
+
databaseUrl: string;
|
|
100
|
+
secret: string;
|
|
101
|
+
baseURL?: string;
|
|
102
|
+
}): AdminRecovery {
|
|
103
|
+
if (!opts.databaseUrl) {
|
|
104
|
+
throw new AdminRecoveryConfigError("DATABASE_URL is required.");
|
|
105
|
+
}
|
|
106
|
+
if (!opts.secret) {
|
|
107
|
+
throw new AdminRecoveryConfigError("BETTER_AUTH_SECRET is required.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { db, client } = createDatabase({ url: opts.databaseUrl });
|
|
111
|
+
const auth = createAuth({
|
|
112
|
+
db,
|
|
113
|
+
secret: opts.secret,
|
|
114
|
+
baseURL: opts.baseURL ?? "http://localhost:3002",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
async create({ email, password, name }) {
|
|
119
|
+
try {
|
|
120
|
+
// Shared scrypt-correct minting via the internal adapter (NOT public
|
|
121
|
+
// sign-up, which `disableSignUp` now blocks). `CreatedAdmin` is the same
|
|
122
|
+
// non-secret shape as `AdminSummary` (id/email/name/createdAt string).
|
|
123
|
+
return await createAdminUser({ auth, email, name, password });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Re-message a duplicate without leaking internals; never echo the
|
|
126
|
+
// password. `createAdminUser` throws AdminAlreadyExistsError with a
|
|
127
|
+
// message that already points at `reset`.
|
|
128
|
+
if (err instanceof AdminAlreadyExistsError) {
|
|
129
|
+
throw new Error(err.message);
|
|
130
|
+
}
|
|
131
|
+
if (err instanceof Error) {
|
|
132
|
+
throw new Error(`Failed to create admin: ${err.message}`);
|
|
133
|
+
}
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async reset({ email, password, revokeSessions }) {
|
|
139
|
+
const ctx = await auth.$context;
|
|
140
|
+
const found = await ctx.internalAdapter.findUserByEmail(email, {
|
|
141
|
+
includeAccounts: true,
|
|
142
|
+
});
|
|
143
|
+
if (!found) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`No admin with email "${email}". ` +
|
|
146
|
+
"Use `hogsend studio admin create` to create one.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Hash via better-auth's server API — scrypt, identical to the running
|
|
151
|
+
// app. NO raw SQL password write.
|
|
152
|
+
const hashed = await ctx.password.hash(password);
|
|
153
|
+
const hasCredential = found.accounts?.some(
|
|
154
|
+
(a) => a.providerId === "credential",
|
|
155
|
+
);
|
|
156
|
+
if (hasCredential) {
|
|
157
|
+
await ctx.internalAdapter.updatePassword(found.user.id, hashed);
|
|
158
|
+
} else {
|
|
159
|
+
await ctx.internalAdapter.createAccount({
|
|
160
|
+
userId: found.user.id,
|
|
161
|
+
providerId: "credential",
|
|
162
|
+
accountId: found.user.id,
|
|
163
|
+
password: hashed,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A recovery reset should not leave old (possibly leaked) sessions alive.
|
|
168
|
+
if (revokeSessions) {
|
|
169
|
+
await ctx.internalAdapter.deleteSessions(found.user.id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return toSummary(found.user);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
async list() {
|
|
176
|
+
// Plain Drizzle read — only non-secret columns. The password/hash column
|
|
177
|
+
// lives on `account` and is never selected here.
|
|
178
|
+
const rows = await db
|
|
179
|
+
.select({
|
|
180
|
+
id: user.id,
|
|
181
|
+
email: user.email,
|
|
182
|
+
name: user.name,
|
|
183
|
+
createdAt: user.createdAt,
|
|
184
|
+
})
|
|
185
|
+
.from(user);
|
|
186
|
+
return rows.map((r) => toSummary(r));
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async close() {
|
|
190
|
+
await client.end();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|