Skip to content

Commit 673beb1

Browse files
committed
fix: switch users CLI to shell-escaped SQL literals instead of psql -v binding
`pnpm openmapx users verify|promote|demote` all built `WHERE email = :'email'` and relied on `psql -v email=…` to expand it. Under `psql -c` in modern psql (15+, including the 18-3.6 image), the variable substitution is silently a no-op — the literal `:'email'` ended up in the SQL and produced `syntax error at or near ":"`. Same root cause as the earlier postgis-password regression. Centralise the email update into one helper that quotes the value with a shell-side single-quote-doubling escape (the only character that breaks a standard-conforming SQL string literal). All three commands route through it; the demote command also collapses to a one-liner instead of duplicating the docker-compose-exec wiring.
1 parent b0493c8 commit 673beb1

1 file changed

Lines changed: 64 additions & 129 deletions

File tree

packages/cli/src/commands/users.ts

Lines changed: 64 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ async function resolvePostgresTarget(rootDir?: string): Promise<PostgresTarget>
3333
};
3434
}
3535

36+
/**
37+
* Quote a string for safe interpolation into a SQL string literal. Postgres
38+
* standard-conforming mode treats `'` as the only character that closes a
39+
* literal, so doubling it is sufficient. We rely on this rather than psql's
40+
* `-v name=…` + `:'name'` substitution because that path is silently a no-op
41+
* under `psql -c` in modern psql (15+) — the literal `:'name'` ends up in
42+
* the SQL and produces a syntax error.
43+
*/
44+
function quoteSqlLiteral(value: string): string {
45+
return `'${value.replace(/'/g, "''")}'`;
46+
}
47+
3648
/**
3749
* Run `psql -c <sql>` inside the postgis container via the generated
3850
* compose file. Returns trimmed stdout (minus trailing empty lines).
@@ -75,58 +87,51 @@ export async function execPsql(
7587
return (result.stdout ?? "").trim();
7688
}
7789

78-
export async function promoteUserToAdmin(email: string, rootDir?: string): Promise<void> {
79-
if (!email?.includes("@")) {
80-
throw new Error(`"${email}" does not look like an email address`);
81-
}
82-
const target = await resolvePostgresTarget(rootDir);
90+
interface UpdateUserOptions {
91+
/** Email of the user to update. */
92+
email: string;
93+
/** SQL `SET` clause body, e.g. `role = 'admin'`. */
94+
setClause: string;
95+
/** Verb shown in the not-found / multiple-matches error/warning text. */
96+
actionVerb: string;
97+
rootDir?: string;
98+
}
8399

84-
// Parameterise via psql's `-v` / `:'var'` quoting so the email is safely
85-
// escaped by libpq rather than injected into the SQL literal.
86-
const paths = repoPaths(rootDir);
87-
const result = await execa(
88-
"docker",
89-
[
90-
"compose",
91-
"-f",
92-
paths.composeOutPath,
93-
"exec",
94-
"-T",
95-
target.serviceId,
96-
"psql",
97-
"-U",
98-
target.user,
99-
"-d",
100-
target.db,
101-
"-v",
102-
"ON_ERROR_STOP=1",
103-
"--no-psqlrc",
104-
"-A",
105-
"-t",
106-
"-v",
107-
`email=${email}`,
108-
"-c",
109-
`UPDATE "user" SET role = 'admin' WHERE email = :'email' RETURNING id;`,
110-
],
111-
{ cwd: paths.infraDir, reject: false },
112-
);
113-
if (result.exitCode !== 0) {
114-
throw new Error(
115-
`psql failed (exit ${result.exitCode}): ${result.stderr?.trim() || result.stdout?.trim() || "no output"}`,
116-
);
100+
/**
101+
* Run `UPDATE "user" SET <setClause> WHERE email = '<email>' RETURNING id;`
102+
* inside the postgis container. Centralises the email validation, escaping,
103+
* exec wiring, and "no rows matched" error messaging used by every user
104+
* mutation command.
105+
*/
106+
async function updateUserByEmail(opts: UpdateUserOptions): Promise<string[]> {
107+
if (!opts.email?.includes("@")) {
108+
throw new Error(`"${opts.email}" does not look like an email address`);
117109
}
118-
const updatedIds = (result.stdout ?? "")
110+
const target = await resolvePostgresTarget(opts.rootDir);
111+
const sql = `UPDATE "user" SET ${opts.setClause} WHERE email = ${quoteSqlLiteral(opts.email)} RETURNING id;`;
112+
const stdout = await execPsql(target, sql, opts.rootDir);
113+
const ids = stdout
119114
.split("\n")
120115
.map((s) => s.trim())
121116
.filter(Boolean);
122-
if (updatedIds.length === 0) {
117+
if (ids.length === 0) {
123118
throw new Error(
124-
`No user found with email "${email}". Sign up through the web UI first, then re-run this command.`,
119+
`No user found with email "${opts.email}". Sign up through the web UI first, then re-run this command.`,
125120
);
126121
}
127-
if (updatedIds.length > 1) {
128-
log.warn(`${updatedIds.length} users matched "${email}" — promoted all of them`);
122+
if (ids.length > 1) {
123+
log.warn(`${ids.length} users matched "${opts.email}" — ${opts.actionVerb} all of them`);
129124
}
125+
return ids;
126+
}
127+
128+
export async function promoteUserToAdmin(email: string, rootDir?: string): Promise<void> {
129+
await updateUserByEmail({
130+
email,
131+
setClause: "role = 'admin'",
132+
actionVerb: "promoted",
133+
rootDir,
134+
});
130135
}
131136

132137
/**
@@ -137,54 +142,21 @@ export async function promoteUserToAdmin(email: string, rootDir?: string): Promi
137142
* configures SMTP, and self-service signup works for everyone else.
138143
*/
139144
export async function markEmailVerified(email: string, rootDir?: string): Promise<void> {
140-
if (!email?.includes("@")) {
141-
throw new Error(`"${email}" does not look like an email address`);
142-
}
143-
const target = await resolvePostgresTarget(rootDir);
144-
const paths = repoPaths(rootDir);
145-
const result = await execa(
146-
"docker",
147-
[
148-
"compose",
149-
"-f",
150-
paths.composeOutPath,
151-
"exec",
152-
"-T",
153-
target.serviceId,
154-
"psql",
155-
"-U",
156-
target.user,
157-
"-d",
158-
target.db,
159-
"-v",
160-
"ON_ERROR_STOP=1",
161-
"--no-psqlrc",
162-
"-A",
163-
"-t",
164-
"-v",
165-
`email=${email}`,
166-
"-c",
167-
`UPDATE "user" SET email_verified = true WHERE email = :'email' RETURNING id;`,
168-
],
169-
{ cwd: paths.infraDir, reject: false },
170-
);
171-
if (result.exitCode !== 0) {
172-
throw new Error(
173-
`psql failed (exit ${result.exitCode}): ${result.stderr?.trim() || result.stdout?.trim() || "no output"}`,
174-
);
175-
}
176-
const updatedIds = (result.stdout ?? "")
177-
.split("\n")
178-
.map((s) => s.trim())
179-
.filter(Boolean);
180-
if (updatedIds.length === 0) {
181-
throw new Error(
182-
`No user found with email "${email}". Sign up through the web UI first, then re-run this command.`,
183-
);
184-
}
185-
if (updatedIds.length > 1) {
186-
log.warn(`${updatedIds.length} users matched "${email}" — verified all of them`);
187-
}
145+
await updateUserByEmail({
146+
email,
147+
setClause: "email_verified = true",
148+
actionVerb: "verified",
149+
rootDir,
150+
});
151+
}
152+
153+
export async function demoteUserFromAdmin(email: string, rootDir?: string): Promise<void> {
154+
await updateUserByEmail({
155+
email,
156+
setClause: "role = 'user'",
157+
actionVerb: "demoted",
158+
rootDir,
159+
});
188160
}
189161

190162
export async function listUsers(
@@ -261,44 +233,7 @@ export function registerUsersCommands(program: Command): void {
261233
.description("Remove admin role from a user (sets role back to user)")
262234
.action(async (email: string) => {
263235
try {
264-
const target = await resolvePostgresTarget();
265-
const paths = repoPaths();
266-
const result = await execa(
267-
"docker",
268-
[
269-
"compose",
270-
"-f",
271-
paths.composeOutPath,
272-
"exec",
273-
"-T",
274-
target.serviceId,
275-
"psql",
276-
"-U",
277-
target.user,
278-
"-d",
279-
target.db,
280-
"-v",
281-
"ON_ERROR_STOP=1",
282-
"--no-psqlrc",
283-
"-A",
284-
"-t",
285-
"-v",
286-
`email=${email}`,
287-
"-c",
288-
`UPDATE "user" SET role = 'user' WHERE email = :'email' RETURNING id;`,
289-
],
290-
{ cwd: paths.infraDir, reject: false },
291-
);
292-
if (result.exitCode !== 0) {
293-
throw new Error(result.stderr?.trim() || result.stdout?.trim() || "psql failed");
294-
}
295-
const ids = (result.stdout ?? "")
296-
.split("\n")
297-
.map((s) => s.trim())
298-
.filter(Boolean);
299-
if (ids.length === 0) {
300-
throw new Error(`No user found with email "${email}"`);
301-
}
236+
await demoteUserFromAdmin(email);
302237
log.ok(`Demoted ${email} to user`);
303238
} catch (err) {
304239
log.err((err as Error).message);

0 commit comments

Comments
 (0)