delete_user_totally.js 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. #!/usr/bin/env node
  2. const path = require("node:path");
  3. const readline = require("node:readline/promises");
  4. const { stdin, stdout } = require("node:process");
  5. const { parseArgs } = require("node:util");
  6. const {
  7. buildAdminGraphDeleteUrl,
  8. buildUserGraphsSql,
  9. buildWranglerArgs,
  10. defaultConfigPath,
  11. fail,
  12. formatUserGraphsResult,
  13. parseWranglerResults,
  14. printUserGraphsTable,
  15. runWranglerQuery,
  16. } = require("./graph_user_lib");
  17. function printHelp() {
  18. console.log(`Delete a db-sync user and all related data from a remote D1 environment.
  19. Usage:
  20. node worker/scripts/delete_user_totally.js --username <username> [--env prod]
  21. node worker/scripts/delete_user_totally.js --user-id <user-id> [--env prod]
  22. Options:
  23. --username <username> Look up the target user by username.
  24. --user-id <user-id> Look up the target user by user id.
  25. --env <env> Wrangler environment to use. Defaults to "prod".
  26. --database <name> D1 binding or database name. Defaults to "DB".
  27. --config <path> Wrangler config path. Defaults to worker/wrangler.toml.
  28. --base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
  29. --admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
  30. --help Show this message.
  31. `);
  32. }
  33. function parseCliArgs(argv) {
  34. const { values } = parseArgs({
  35. args: argv,
  36. options: {
  37. username: { type: "string" },
  38. "user-id": { type: "string" },
  39. env: { type: "string", default: "prod" },
  40. database: { type: "string", default: "DB" },
  41. config: { type: "string", default: defaultConfigPath },
  42. "base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
  43. "admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
  44. help: { type: "boolean", default: false },
  45. },
  46. strict: true,
  47. allowPositionals: false,
  48. });
  49. if (values.help) {
  50. printHelp();
  51. process.exit(0);
  52. }
  53. const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
  54. if (lookupCount !== 1) {
  55. fail("Pass exactly one of --username or --user-id.");
  56. }
  57. return {
  58. lookupField: values.username ? "username" : "id",
  59. lookupLabel: values.username ? "username" : "user-id",
  60. lookupValue: values.username ?? values["user-id"],
  61. env: values.env,
  62. database: values.database,
  63. config: path.resolve(values.config),
  64. baseUrl: values["base-url"],
  65. adminToken: values["admin-token"],
  66. };
  67. }
  68. function escapeSqlValue(value) {
  69. return value.replaceAll("'", "''");
  70. }
  71. function runSelectQuery(options, sql) {
  72. const wranglerArgs = buildWranglerArgs({
  73. database: options.database,
  74. config: options.config,
  75. env: options.env,
  76. sql,
  77. });
  78. return parseWranglerResults(runWranglerQuery(wranglerArgs));
  79. }
  80. function runMutationQuery(options, sql) {
  81. const wranglerArgs = buildWranglerArgs({
  82. database: options.database,
  83. config: options.config,
  84. env: options.env,
  85. sql,
  86. });
  87. const output = runWranglerQuery(wranglerArgs);
  88. if (!Array.isArray(output) || output.length === 0) {
  89. throw new Error("Unexpected empty response from wrangler.");
  90. }
  91. output.forEach((statement, index) => {
  92. if (!statement.success) {
  93. throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`);
  94. }
  95. });
  96. return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0);
  97. }
  98. function sqlCountToNumber(value) {
  99. const numericValue = Number(value);
  100. return Number.isFinite(numericValue) ? numericValue : 0;
  101. }
  102. function isDeleteConfirmationAccepted(answer, userId) {
  103. const normalizedAnswer = answer.trim();
  104. return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`;
  105. }
  106. async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) {
  107. const rl = readline.createInterface({ input: stdin, output: stdout });
  108. try {
  109. const answer = await rl.question(
  110. `Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `,
  111. );
  112. return isDeleteConfirmationAccepted(answer, user.user_id);
  113. } finally {
  114. rl.close();
  115. }
  116. }
  117. async function deleteOwnedGraphs(options, ownedGraphs) {
  118. for (const graph of ownedGraphs) {
  119. const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
  120. method: "DELETE",
  121. headers: {
  122. "x-db-sync-admin-token": options.adminToken,
  123. },
  124. });
  125. if (!response.ok) {
  126. const payload = await response.text();
  127. fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`);
  128. }
  129. }
  130. }
  131. async function main() {
  132. const options = parseCliArgs(process.argv.slice(2));
  133. const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false }));
  134. const result = formatUserGraphsResult(graphRows);
  135. if (!result) {
  136. fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
  137. }
  138. const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner");
  139. const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner");
  140. printUserGraphsTable(result, "Graphs linked to user");
  141. console.log(`Owned graphs: ${ownedGraphs.length}`);
  142. console.log(`Member graphs: ${memberGraphs.length}`);
  143. if (ownedGraphs.length > 0 && !options.baseUrl) {
  144. fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
  145. }
  146. if (ownedGraphs.length > 0 && !options.adminToken) {
  147. fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
  148. }
  149. const confirmed = await confirmDeletion({
  150. user: result.user,
  151. ownedGraphsCount: ownedGraphs.length,
  152. memberGraphsCount: memberGraphs.length,
  153. });
  154. if (!confirmed) {
  155. console.log("Aborted.");
  156. return;
  157. }
  158. if (ownedGraphs.length > 0) {
  159. await deleteOwnedGraphs(options, ownedGraphs);
  160. }
  161. const escapedUserId = escapeSqlValue(result.user.user_id);
  162. const remainingOwnedGraphRows = runSelectQuery(
  163. options,
  164. `select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`,
  165. );
  166. const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count);
  167. if (remainingOwnedGraphCount > 0) {
  168. fail(
  169. `Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`,
  170. );
  171. }
  172. const deletedGraphAesKeys = runMutationQuery(
  173. options,
  174. `delete from graph_aes_keys where user_id = '${escapedUserId}'`,
  175. );
  176. const deletedGraphMembers = runMutationQuery(
  177. options,
  178. `delete from graph_members where user_id = '${escapedUserId}'`,
  179. );
  180. const clearedInvitedBy = runMutationQuery(
  181. options,
  182. `update graph_members set invited_by = null where invited_by = '${escapedUserId}'`,
  183. );
  184. const deletedUserRsaKeys = runMutationQuery(
  185. options,
  186. `delete from user_rsa_keys where user_id = '${escapedUserId}'`,
  187. );
  188. const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`);
  189. if (deletedUsers !== 1) {
  190. fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`);
  191. }
  192. const userRowsAfterDelete = runSelectQuery(
  193. options,
  194. `select id from users where id = '${escapedUserId}' limit 1`,
  195. );
  196. if (userRowsAfterDelete.length > 0) {
  197. fail(`User ${result.user.user_id} still exists after deletion.`);
  198. }
  199. console.table([
  200. { step: "owned graphs deleted", rows: ownedGraphs.length },
  201. { step: "graph_aes_keys deleted", rows: deletedGraphAesKeys },
  202. { step: "graph_members deleted", rows: deletedGraphMembers },
  203. { step: "graph_members invited_by cleared", rows: clearedInvitedBy },
  204. { step: "user_rsa_keys deleted", rows: deletedUserRsaKeys },
  205. { step: "users deleted", rows: deletedUsers },
  206. ]);
  207. console.log(`Deleted user ${result.user.user_id} successfully.`);
  208. }
  209. if (require.main === module) {
  210. main().catch((error) => {
  211. fail(error instanceof Error ? error.message : String(error));
  212. });
  213. }
  214. module.exports = {
  215. confirmDeletion,
  216. isDeleteConfirmationAccepted,
  217. parseCliArgs,
  218. };