@remit/account-worker 0.0.34 → 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.34",
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",
@@ -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,5 +1,4 @@
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
3
  BETTER_SQLITE3_LIMITS,
5
4
  openSqliteDatabases,
@@ -10,33 +9,23 @@ import {
10
9
  syncStores,
11
10
  } from "@remit/drizzle-service";
12
11
  import type { Logger } from "@remit/logger";
12
+ import { createFilesystemStorageService } from "@remit/storage-service/filesystem";
13
+ import { eq } from "drizzle-orm";
13
14
  import type { DeletionCapabilities } from "./deletion-capabilities.js";
14
15
 
15
- // Mirrors `@remit/storage-service`'s filesystem-backend default: bodies
16
- // live under `STORAGE_LOCAL_PATH` (`.remit/storage` when unset). The cascade
17
- // deletes the same `accounts/{accountConfigId}/…` key prefixes the S3 backend
18
- // would, translated to a recursive directory removal.
19
- const storageBasePath = (): string =>
20
- process.env.STORAGE_LOCAL_PATH ?? ".remit/storage";
21
-
22
- /**
23
- * Recursively remove every stored object under a key prefix. Replay-safe:
24
- * `force: true` swallows a missing directory (ENOENT), matching the S3 backend's
25
- * "delete on missing keys is a 200" idempotency.
26
- */
27
16
  const deleteStoragePrefix = async (
28
17
  keyPrefix: string,
29
18
  log: Logger,
30
19
  ): Promise<void> => {
31
- const target = join(storageBasePath(), keyPrefix);
32
- await rm(target, { recursive: true, force: true });
33
- 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");
34
24
  };
35
25
 
36
26
  // The app, mail and search databases are opened once, on first use, and
37
- // shared by both deletes (the native binding loads there, hence async). Built
38
- // lazily so the fanout worker — which signs out but never deletes rows — does
39
- // not open a database handle it will not use.
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.
40
29
  let storesPromise: Promise<SqliteStores> | undefined;
41
30
 
42
31
  const getStores = (): Promise<SqliteStores> => {
@@ -46,28 +35,14 @@ const getStores = (): Promise<SqliteStores> => {
46
35
  return storesPromise;
47
36
  };
48
37
 
49
- /**
50
- * The self-host stack's deletion capabilities (RFC 035/036).
51
- *
52
- * - `signOut`: no-op. Deleting the AccountConfig row severs the session's
53
- * account→data resolution, and there is no federated session store to sign
54
- * out of; better-auth session rows are owned by the auth service, outside this
55
- * worker's data seam.
56
- * - `invalidateContent`: no-op. Caddy proxies `/content/*` straight to the
57
- * backend with no response cache (`deploy/vps/caddy/routes.caddy`), so there
58
- * is nothing to invalidate.
59
- * - `deleteStoragePrefix`: recursive filesystem removal.
60
- * - `cascadeDelete`: the Drizzle cascade over SQLite.
61
- * - `eraseAccountConfig`: one keyed delete per table over SQLite.
62
- */
63
38
  export const buildRelationalDeletionCapabilities =
64
39
  (): DeletionCapabilities => ({
65
40
  assertReady: async () => {},
66
41
  signOut: async (userId, log) => {
67
- log.info(
68
- { userId },
69
- "Sign-out is a no-op on the relational backend; AccountConfig removal severs session data access",
70
- );
42
+ await (await getStores()).app.db
43
+ .delete(auth_user)
44
+ .where(eq(auth_user.id, userId));
45
+ log.info({ userId }, "Auth user erased");
71
46
  },
72
47
  invalidateContent: async (contentPrefix, log) => {
73
48
  log.info(
@@ -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
@@ -7,6 +7,7 @@ import { recordingQueue } from "@remit/data-ports/recording-queue";
7
7
  import {
8
8
  BETTER_SQLITE3_LIMITS,
9
9
  composeSqliteRepositories,
10
+ messageObjectsInMemory,
10
11
  openSqliteDatabases,
11
12
  syncStores,
12
13
  } from "@remit/drizzle-service";
@@ -48,6 +49,7 @@ describe("account delete on a host that runs the finalize step from its own queu
48
49
  close = databases.close;
49
50
  repositories = composeSqliteRepositories(
50
51
  syncStores(databases, BETTER_SQLITE3_LIMITS),
52
+ messageObjectsInMemory(),
51
53
  );
52
54
  });
53
55
 
@@ -81,7 +83,7 @@ describe("account delete on a host that runs the finalize step from its own queu
81
83
  const signedOut: string[] = [];
82
84
 
83
85
  await processAccountDelete(
84
- { type: "AccountDelete", accountConfigId },
86
+ { type: "AccountDelete", accountConfigId, userId: "auth-user-1" },
85
87
  noopLogger,
86
88
  {
87
89
  services,
@@ -91,7 +93,7 @@ describe("account delete on a host that runs the finalize step from its own queu
91
93
  enqueueFinalize: (event) => queue.send({ body: event }),
92
94
  },
93
95
  );
94
- assert.deepEqual(signedOut, [accountConfig.userId]);
96
+ assert.deepEqual(signedOut, ["auth-user-1"]);
95
97
  const [queued] = queue.sent;
96
98
  assert.deepEqual(queued?.body, {
97
99
  type: "FinalizeAccountDelete",
@@ -26,7 +26,7 @@ export const processAccountDelete = async (
26
26
  return;
27
27
  }
28
28
 
29
- await signOut(accountConfig.userId, log);
29
+ await signOut(event.userId, log);
30
30
  await enqueueFinalize({ type: "FinalizeAccountDelete", accountConfigId });
31
31
  log.info({ accountConfigId }, "Enqueued finalize event");
32
32
  };
@@ -10,6 +10,7 @@ import {
10
10
  BETTER_SQLITE3_LIMITS,
11
11
  composeSqliteRepositories,
12
12
  mailboxTable,
13
+ messageObjectsInMemory,
13
14
  messageTable,
14
15
  openSqliteDatabases,
15
16
  syncStores,
@@ -169,6 +170,7 @@ describe("processAccountExport over SQLite repositories and filesystem storage",
169
170
  close = databases.close;
170
171
  repositories = composeSqliteRepositories(
171
172
  syncStores(databases, BETTER_SQLITE3_LIMITS),
173
+ messageObjectsInMemory(),
172
174
  );
173
175
  storage = createFilesystemStorageService(join(directory, "storage"));
174
176
  });
@@ -14,6 +14,7 @@ const buildServices = (): CascadeServices =>
14
14
  const deleteEvent: AccountDeleteEvent = {
15
15
  type: "AccountDelete",
16
16
  accountConfigId: "cfg-1",
17
+ userId: "user-1",
17
18
  };
18
19
 
19
20
  describe("processAccountFanout on account delete", () => {
@@ -13,6 +13,7 @@ import {
13
13
  BETTER_SQLITE3_LIMITS,
14
14
  createSqliteRepositories,
15
15
  type DrizzleThreadMessageRepository,
16
+ messageObjectsInMemory,
16
17
  } from "@remit/drizzle-service";
17
18
  import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
18
19
  import { noopLogger } from "@remit/logger";
@@ -193,6 +194,7 @@ const world = async (filters: FilterItem[]): Promise<World> => {
193
194
  search: store.db,
194
195
  },
195
196
  BETTER_SQLITE3_LIMITS,
197
+ messageObjectsInMemory(),
196
198
  ).threadMessage;
197
199
  const { inbox, archive } = await seedThreads(threadMessage);
198
200
  const updates: Update[] = [];