@remit/account-worker 0.0.33 → 0.0.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/account-worker",
3
- "version": "0.0.33",
3
+ "version": "0.0.35",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -37,6 +37,7 @@
37
37
  "dev": "node --import tsx src/poller.ts"
38
38
  },
39
39
  "dependencies": {
40
+ "@remit/auth-service": "*",
40
41
  "@remit/logger": "*",
41
42
  "@remit/data-ports": "*",
42
43
  "@remit/logger-lambda": "*",
@@ -47,6 +48,7 @@
47
48
  "@remit/electrodb-entities": "*",
48
49
  "@aws-sdk/client-sqs": "^3.1090.0",
49
50
  "archiver": "^8.0.0",
51
+ "drizzle-orm": "^0.45.2",
50
52
  "electrodb": "^3.9.1",
51
53
  "expect-env": "^1.0.1",
52
54
  "@types/archiver": "^8.0.0",
@@ -54,6 +56,7 @@
54
56
  },
55
57
  "devDependencies": {
56
58
  "@aws-sdk/core": "*",
59
+ "better-sqlite3": "^12.11.1",
57
60
  "esbuild": "^0.28.1"
58
61
  },
59
62
  "license": "MIT",
package/src/cascade.ts CHANGED
@@ -46,7 +46,30 @@ export interface CascadeServices {
46
46
  messageLabelService: IMessageLabelRepository;
47
47
  }
48
48
 
49
- export const cascadeServicesOf = (client: RemitClient): CascadeServices => ({
49
+ export type CascadeRepositories = Pick<
50
+ RemitClient,
51
+ | "accountConfig"
52
+ | "account"
53
+ | "address"
54
+ | "mailbox"
55
+ | "message"
56
+ | "messageFlag"
57
+ | "envelope"
58
+ | "outboxMessage"
59
+ | "threadMessage"
60
+ | "mailboxLock"
61
+ | "placementMove"
62
+ | "flagPush"
63
+ | "accountExportRequest"
64
+ | "accountSetting"
65
+ | "filter"
66
+ | "label"
67
+ | "messageLabel"
68
+ >;
69
+
70
+ export const cascadeServicesOf = (
71
+ client: CascadeRepositories,
72
+ ): CascadeServices => ({
50
73
  accountConfigService: client.accountConfig,
51
74
  accountService: client.account,
52
75
  addressService: client.address,
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { before, describe, it } from "node:test";
6
+ import { fileURLToPath } from "node:url";
7
+ import { type Auth, createAuth } from "@remit/auth-service";
8
+ import { auth_session, auth_user } from "@remit/auth-service/schema";
9
+ import { noopLogger } from "@remit/logger";
10
+ import Database from "better-sqlite3";
11
+ import { drizzle } from "drizzle-orm/better-sqlite3";
12
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
13
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
14
+ import { buildRelationalDeletionCapabilities } from "./compose-relational.js";
15
+
16
+ const ORIGIN = "https://reader.remit.test";
17
+ const PASSWORD = "correct horse battery staple";
18
+
19
+ const MIGRATIONS = new URL(
20
+ "../../../deploy/vps/migrations-sqlite/",
21
+ import.meta.url,
22
+ );
23
+
24
+ const mcpDeviceCode = sqliteTable("mcp_device_code", {
25
+ id: text("id").primaryKey(),
26
+ deviceCode: text("device_code").notNull(),
27
+ userCode: text("user_code").notNull(),
28
+ userId: text("user_id"),
29
+ expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
30
+ status: text("status").notNull(),
31
+ });
32
+
33
+ interface SignedUp {
34
+ userId: string;
35
+ cookie: string;
36
+ }
37
+
38
+ const signUp = async (auth: Auth, email: string): Promise<SignedUp> => {
39
+ const response = await auth.api.signUpEmail({
40
+ body: { email, password: PASSWORD, name: email },
41
+ asResponse: true,
42
+ });
43
+ assert.equal(response.status, 200);
44
+ const { user } = (await response.json()) as { user: { id: string } };
45
+ const cookie = response.headers
46
+ .getSetCookie()
47
+ .map((value) => value.split(";")[0])
48
+ .join("; ");
49
+ return { userId: user.id, cookie };
50
+ };
51
+
52
+ const tokenStatus = async (auth: Auth, cookie: string): Promise<number> =>
53
+ (
54
+ await auth.handler(
55
+ new Request(`${ORIGIN}/api/auth/token`, { headers: { cookie } }),
56
+ )
57
+ ).status;
58
+
59
+ describe("relational deletion capabilities — login user erase", () => {
60
+ let filename: string;
61
+ let auth: Auth;
62
+ let erased: SignedUp;
63
+ let kept: SignedUp;
64
+
65
+ const rowsOf = (): {
66
+ users: string[];
67
+ sessionUsers: string[];
68
+ deviceCodeUsers: (string | null)[];
69
+ } => {
70
+ const sqlite = new Database(filename, { readonly: true });
71
+ const db = drizzle(sqlite);
72
+ const users = db
73
+ .select({ id: auth_user.id })
74
+ .from(auth_user)
75
+ .all()
76
+ .map((row) => row.id);
77
+ const sessionUsers = db
78
+ .select({ userId: auth_session.userId })
79
+ .from(auth_session)
80
+ .all()
81
+ .map((row) => row.userId);
82
+ const deviceCodeUsers = db
83
+ .select({ userId: mcpDeviceCode.userId })
84
+ .from(mcpDeviceCode)
85
+ .all()
86
+ .map((row) => row.userId);
87
+ sqlite.close();
88
+ return { users, sessionUsers, deviceCodeUsers };
89
+ };
90
+
91
+ before(async () => {
92
+ const dir = await mkdtemp(join(tmpdir(), "remit-relational-auth-"));
93
+ filename = join(dir, "app.db");
94
+ process.env.SQLITE_APP_DB_PATH = filename;
95
+ process.env.SQLITE_MAIL_DB_PATH = join(dir, "mail.db");
96
+ process.env.SQLITE_SEARCH_DB_PATH = join(dir, "search.db");
97
+
98
+ const migrating = new Database(filename);
99
+ migrating.pragma("journal_mode = WAL");
100
+ migrating.pragma("foreign_keys = ON");
101
+ for (const set of ["app", "mcp"]) {
102
+ migrate(drizzle(migrating), {
103
+ migrationsFolder: fileURLToPath(new URL(set, MIGRATIONS)),
104
+ migrationsTable: `__drizzle_migrations_${set}`,
105
+ });
106
+ }
107
+ migrating.close();
108
+
109
+ auth = await createAuth({
110
+ connectionString: filename,
111
+ secret: "relational-auth-secret-0123456789abcdef",
112
+ baseURL: ORIGIN,
113
+ selfSignUpEnabled: true,
114
+ });
115
+ erased = await signUp(auth, "erased@remit.test");
116
+ kept = await signUp(auth, "kept@remit.test");
117
+
118
+ const seeding = new Database(filename);
119
+ drizzle(seeding)
120
+ .insert(mcpDeviceCode)
121
+ .values(
122
+ [erased, kept].map(({ userId }) => ({
123
+ id: userId,
124
+ deviceCode: `device-${userId}`,
125
+ userCode: `user-${userId}`,
126
+ userId,
127
+ expiresAt: new Date(Date.now() + 600_000),
128
+ status: "approved",
129
+ })),
130
+ )
131
+ .run();
132
+ seeding.close();
133
+ });
134
+
135
+ it("deletes the user, its sessions and device codes, and refuses the old session cookie", async () => {
136
+ assert.equal(await tokenStatus(auth, erased.cookie), 200);
137
+
138
+ await buildRelationalDeletionCapabilities().signOut(
139
+ erased.userId,
140
+ noopLogger,
141
+ );
142
+
143
+ const { users, sessionUsers, deviceCodeUsers } = rowsOf();
144
+ assert.deepEqual(users, [kept.userId]);
145
+ assert.deepEqual(sessionUsers, [kept.userId]);
146
+ assert.deepEqual(deviceCodeUsers, [kept.userId]);
147
+ assert.equal(await tokenStatus(auth, erased.cookie), 401);
148
+ assert.equal(await tokenStatus(auth, kept.cookie), 200);
149
+ });
150
+ });
@@ -1,67 +1,48 @@
1
- import { rm } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { auth_user } from "@remit/auth-service/schema";
3
2
  import {
4
- type CascadeDeleter,
5
- createSqliteCascadeDeleter,
3
+ BETTER_SQLITE3_LIMITS,
4
+ openSqliteDatabases,
5
+ runDrizzleAccountConfigErase,
6
+ runDrizzleCascadeDelete,
7
+ type SqliteStores,
6
8
  sqliteDatabasePathsFromEnv,
9
+ syncStores,
7
10
  } from "@remit/drizzle-service";
8
11
  import type { Logger } from "@remit/logger";
12
+ import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
13
+ import { eq } from "drizzle-orm";
9
14
  import type { DeletionCapabilities } from "./deletion-capabilities.js";
10
15
 
11
- // Mirrors `@remit/storage-service`'s filesystem-backend default: bodies
12
- // live under `STORAGE_LOCAL_PATH` (`.remit/storage` when unset). The cascade
13
- // deletes the same `accounts/{accountConfigId}/…` key prefixes the S3 backend
14
- // would, translated to a recursive directory removal.
15
- const storageBasePath = (): string =>
16
- process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
17
-
18
- /**
19
- * Recursively remove every stored object under a key prefix. Replay-safe:
20
- * `force: true` swallows a missing directory (ENOENT), matching the S3 backend's
21
- * "delete on missing keys is a 200" idempotency.
22
- */
23
16
  const deleteStoragePrefix = async (
24
17
  keyPrefix: string,
25
18
  log: Logger,
26
19
  ): Promise<void> => {
27
- const target = join(storageBasePath(), keyPrefix);
28
- await rm(target, { recursive: true, force: true });
29
- log.info({ keyPrefix, target }, "Filesystem storage prefix cleanup complete");
20
+ await createFilesystemStorageService(
21
+ process.env.STORAGE_LOCAL_PATH ?? ".remit/storage",
22
+ ).deletePrefix(keyPrefix);
23
+ log.info({ keyPrefix }, "Storage prefix cleanup complete");
30
24
  };
31
25
 
32
- // The Drizzle cascade deleter is opened once, on first use, and reused: it
33
- // opens the app, mail and search databases (the native binding loads there,
34
- // hence async). Built lazily so the fanout worker — which signs out but never
35
- // cascade-deletes — does not open a database handle it will not use.
36
- let deleterPromise: Promise<CascadeDeleter> | undefined;
26
+ // The app, mail and search databases are opened once, on first use, and
27
+ // shared by every delete (the native binding loads there, hence async). Built
28
+ // lazily so a worker that never deletes does not open a database handle.
29
+ let storesPromise: Promise<SqliteStores> | undefined;
37
30
 
38
- const getDeleter = (): Promise<CascadeDeleter> => {
39
- if (!deleterPromise)
40
- deleterPromise = createSqliteCascadeDeleter(sqliteDatabasePathsFromEnv());
41
- return deleterPromise;
31
+ const getStores = (): Promise<SqliteStores> => {
32
+ storesPromise ??= openSqliteDatabases(sqliteDatabasePathsFromEnv()).then(
33
+ (databases) => syncStores(databases, BETTER_SQLITE3_LIMITS),
34
+ );
35
+ return storesPromise;
42
36
  };
43
37
 
44
- /**
45
- * The self-host stack's deletion capabilities (RFC 035/036).
46
- *
47
- * - `signOut`: no-op. Deleting the AccountConfig row severs the session's
48
- * account→data resolution, and there is no federated session store to sign
49
- * out of; better-auth session rows are owned by the auth service, outside this
50
- * worker's data seam.
51
- * - `invalidateContent`: no-op. Caddy proxies `/content/*` straight to the
52
- * backend with no response cache (`deploy/vps/caddy/routes.caddy`), so there
53
- * is nothing to invalidate.
54
- * - `deleteStoragePrefix`: recursive filesystem removal.
55
- * - `cascadeDelete`: the Drizzle cascade over SQLite.
56
- */
57
38
  export const buildRelationalDeletionCapabilities =
58
39
  (): DeletionCapabilities => ({
59
40
  assertReady: async () => {},
60
41
  signOut: async (userId, log) => {
61
- log.info(
62
- { userId },
63
- "Sign-out is a no-op on the relational backend; AccountConfig removal severs session data access",
64
- );
42
+ await (await getStores()).app.db
43
+ .delete(auth_user)
44
+ .where(eq(auth_user.id, userId));
45
+ log.info({ userId }, "Auth user erased");
65
46
  },
66
47
  invalidateContent: async (contentPrefix, log) => {
67
48
  log.info(
@@ -71,8 +52,8 @@ export const buildRelationalDeletionCapabilities =
71
52
  },
72
53
  deleteStoragePrefix: (keyPrefix, log) =>
73
54
  deleteStoragePrefix(keyPrefix, log),
74
- cascadeDelete: async (entities, log) => {
75
- const deleter = await getDeleter();
76
- await deleter(entities, log);
77
- },
55
+ cascadeDelete: async (entities, log) =>
56
+ runDrizzleCascadeDelete(await getStores(), entities, log),
57
+ eraseAccountConfig: async (accountConfigId, log) =>
58
+ runDrizzleAccountConfigErase(await getStores(), accountConfigId, log),
78
59
  });
@@ -1,25 +1,6 @@
1
1
  import type { Logger } from "@remit/logger";
2
2
  import type { CascadeEntity } from "./cascade.js";
3
3
 
4
- /**
5
- * The four steps of the deletion cascade that vary by deployment. Everything
6
- * else — enumerating the AccountConfig's rows, the FIFO purge orchestration, the
7
- * queue sends — is backend-neutral and lives in the handlers unchanged.
8
- *
9
- * Two composition roots build this:
10
- *
11
- * - The DynamoDB composition (AWS custody stack): Cognito global sign-out,
12
- * CloudFront invalidation, raw-S3 prefix delete, DynamoDB `BatchWriteItem`
13
- * cascade. Byte-for-byte the pre-split behavior. It imports the AWS SDK /
14
- * ElectroDB graph and lives outside this shared, open-core module; it is
15
- * injected here through `setDeletionCapabilities` so that graph never reaches
16
- * the relational tree.
17
- * - `compose-relational.ts` (self-host stack, RFC 035/036): no-op sign-out
18
- * (deleting the AccountConfig severs the session's data resolution; there is
19
- * no CDN to sign out of), no-op invalidation (Caddy proxies `/content/*`
20
- * straight to the backend with no cache), filesystem prefix delete, and the
21
- * Drizzle cascade over SQLite.
22
- */
23
4
  export interface DeletionCapabilities {
24
5
  /**
25
6
  * Validate every precondition the destructive steps depend on, BEFORE any row
@@ -29,10 +10,6 @@ export interface DeletionCapabilities {
29
10
  * Relational is a no-op — its steps carry no such deploy-time configuration.
30
11
  */
31
12
  assertReady(log: Logger): Promise<void>;
32
- /**
33
- * Invalidate the deleted user's active auth sessions. AWS: Cognito
34
- * `AdminUserGlobalSignOut`. Relational: no-op.
35
- */
36
13
  signOut(userId: string, log: Logger): Promise<void>;
37
14
  /**
38
15
  * Purge any CDN/edge cache under a tenant/account content prefix
@@ -52,4 +29,9 @@ export interface DeletionCapabilities {
52
29
  * through its repository as the cascade-in-progress marker.
53
30
  */
54
31
  cascadeDelete(entities: CascadeEntity[], log: Logger): Promise<void>;
32
+ /**
33
+ * Remove every row an account config owns except the AccountConfig row,
34
+ * which the caller deletes last. Relational: one keyed delete per table.
35
+ */
36
+ eraseAccountConfig(accountConfigId: string, log: Logger): Promise<void>;
55
37
  }
@@ -0,0 +1,137 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { after, before, describe, it } from "node:test";
6
+ import { recordingQueue } from "@remit/data-ports/recording-queue";
7
+ import {
8
+ BETTER_SQLITE3_LIMITS,
9
+ composeSqliteRepositories,
10
+ messageObjectsInMemory,
11
+ openSqliteDatabases,
12
+ syncStores,
13
+ } from "@remit/drizzle-service";
14
+ import { createShippedSqliteFiles } from "@remit/drizzle-service/test-sqlite";
15
+ import { noopLogger } from "@remit/logger";
16
+ import { cascadeServicesOf } from "../cascade.js";
17
+ import { buildRelationalDeletionCapabilities } from "../compose-relational.js";
18
+ import type { AccountDeleteFinalizeEvent } from "../events.js";
19
+ import {
20
+ finalizeAccountDelete,
21
+ processAccountDelete,
22
+ } from "./account-delete.js";
23
+
24
+ const exists = (path: string): Promise<boolean> =>
25
+ stat(path).then(
26
+ () => true,
27
+ () => false,
28
+ );
29
+
30
+ const writeUnder = async (base: string, key: string): Promise<string> => {
31
+ const full = join(base, key);
32
+ await mkdir(dirname(full), { recursive: true });
33
+ await writeFile(full, "x");
34
+ return full;
35
+ };
36
+
37
+ describe("account delete on a host that runs the finalize step from its own queue", () => {
38
+ const previousStoragePath = process.env.STORAGE_LOCAL_PATH;
39
+ let directory: string;
40
+ let close: () => Promise<void>;
41
+ let repositories: ReturnType<typeof composeSqliteRepositories>;
42
+
43
+ before(async () => {
44
+ directory = await mkdtemp(join(tmpdir(), "remit-account-delete-"));
45
+ process.env.STORAGE_LOCAL_PATH = join(directory, "storage");
46
+ const databases = await openSqliteDatabases(
47
+ createShippedSqliteFiles(directory),
48
+ );
49
+ close = databases.close;
50
+ repositories = composeSqliteRepositories(
51
+ syncStores(databases, BETTER_SQLITE3_LIMITS),
52
+ messageObjectsInMemory(),
53
+ );
54
+ });
55
+
56
+ after(async () => {
57
+ await close();
58
+ if (previousStoragePath === undefined)
59
+ delete process.env.STORAGE_LOCAL_PATH;
60
+ else process.env.STORAGE_LOCAL_PATH = previousStoragePath;
61
+ });
62
+
63
+ it("signs out, queues the finalize step, and the finalize step erases the tenant", async () => {
64
+ const accountConfig = await repositories.accountConfig.create({
65
+ userId: "user000000000000000000001",
66
+ });
67
+ const { accountConfigId } = accountConfig;
68
+ await repositories.accountExportRequest.create({
69
+ accountConfigId,
70
+ userId: accountConfig.userId,
71
+ state: "Ready",
72
+ });
73
+ await repositories.label.create({ accountConfigId, name: "Receipts" });
74
+ const storage = process.env.STORAGE_LOCAL_PATH ?? "";
75
+ const stored = [
76
+ await writeUnder(storage, `accounts/${accountConfigId}/a/body.eml`),
77
+ await writeUnder(storage, `exports/${accountConfigId}/e/export.zip`),
78
+ ];
79
+
80
+ const services = cascadeServicesOf(repositories);
81
+ const capabilities = buildRelationalDeletionCapabilities();
82
+ const queue = recordingQueue();
83
+ const signedOut: string[] = [];
84
+
85
+ await processAccountDelete(
86
+ { type: "AccountDelete", accountConfigId, userId: "auth-user-1" },
87
+ noopLogger,
88
+ {
89
+ services,
90
+ signOut: async (userId) => {
91
+ signedOut.push(userId);
92
+ },
93
+ enqueueFinalize: (event) => queue.send({ body: event }),
94
+ },
95
+ );
96
+ assert.deepEqual(signedOut, ["auth-user-1"]);
97
+ const [queued] = queue.sent;
98
+ assert.deepEqual(queued?.body, {
99
+ type: "FinalizeAccountDelete",
100
+ accountConfigId,
101
+ });
102
+
103
+ const finalize: AccountDeleteFinalizeEvent = {
104
+ type: "FinalizeAccountDelete",
105
+ accountConfigId,
106
+ };
107
+ await finalizeAccountDelete(finalize, noopLogger, {
108
+ services,
109
+ capabilities,
110
+ });
111
+
112
+ assert.deepEqual(
113
+ await repositories.accountConfig.get([accountConfigId]),
114
+ [],
115
+ );
116
+ assert.deepEqual(
117
+ (
118
+ await repositories.accountExportRequest.listByAccountConfig(
119
+ accountConfigId,
120
+ )
121
+ ).items,
122
+ [],
123
+ );
124
+ assert.deepEqual(
125
+ await repositories.label.listByAccountConfig(accountConfigId),
126
+ [],
127
+ );
128
+ for (const path of stored) {
129
+ assert.equal(await exists(path), false, `${path} must be deleted`);
130
+ }
131
+
132
+ await finalizeAccountDelete(finalize, noopLogger, {
133
+ services,
134
+ capabilities,
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,64 @@
1
+ import type { Logger } from "@remit/logger";
2
+ import type { CascadeServices } from "../cascade.js";
3
+ import type { DeletionCapabilities } from "../deletion-port.js";
4
+ import type {
5
+ AccountDeleteEvent,
6
+ AccountDeleteFinalizeEvent,
7
+ } from "../events.js";
8
+
9
+ export interface AccountDeleteDeps {
10
+ services: CascadeServices;
11
+ signOut: DeletionCapabilities["signOut"];
12
+ enqueueFinalize: (event: AccountDeleteFinalizeEvent) => Promise<void>;
13
+ }
14
+
15
+ export const processAccountDelete = async (
16
+ event: AccountDeleteEvent,
17
+ log: Logger,
18
+ { services, signOut, enqueueFinalize }: AccountDeleteDeps,
19
+ ): Promise<void> => {
20
+ const { accountConfigId } = event;
21
+ const [accountConfig] = await services.accountConfigService.get([
22
+ accountConfigId,
23
+ ]);
24
+ if (accountConfig === undefined) {
25
+ log.info({ accountConfigId }, "AccountConfig already erased — no-op");
26
+ return;
27
+ }
28
+
29
+ await signOut(event.userId, log);
30
+ await enqueueFinalize({ type: "FinalizeAccountDelete", accountConfigId });
31
+ log.info({ accountConfigId }, "Enqueued finalize event");
32
+ };
33
+
34
+ export interface AccountDeleteFinalizeDeps {
35
+ services: CascadeServices;
36
+ capabilities: DeletionCapabilities;
37
+ }
38
+
39
+ /**
40
+ * The AccountConfig row is deleted last: while it exists, a replay finds it
41
+ * and runs the whole erase again.
42
+ */
43
+ export const finalizeAccountDelete = async (
44
+ event: AccountDeleteFinalizeEvent,
45
+ log: Logger,
46
+ { services, capabilities }: AccountDeleteFinalizeDeps,
47
+ ): Promise<void> => {
48
+ const { accountConfigId } = event;
49
+ const [accountConfig] = await services.accountConfigService.get([
50
+ accountConfigId,
51
+ ]);
52
+ if (accountConfig === undefined) {
53
+ log.info({ accountConfigId }, "Account erase already complete — no-op");
54
+ return;
55
+ }
56
+
57
+ await capabilities.assertReady(log);
58
+ await capabilities.invalidateContent(accountConfigId, log);
59
+ await capabilities.eraseAccountConfig(accountConfigId, log);
60
+ await capabilities.deleteStoragePrefix(`accounts/${accountConfigId}/`, log);
61
+ await capabilities.deleteStoragePrefix(`exports/${accountConfigId}/`, log);
62
+ await services.accountConfigService.delete(accountConfigId);
63
+ log.info({ accountConfigId }, "AccountConfig deleted; erase complete");
64
+ };