@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 +4 -1
- package/src/compose-relational.auth.test.ts +150 -0
- package/src/compose-relational.ts +13 -38
- package/src/deletion-port.ts +0 -23
- package/src/handlers/account-delete.test.ts +4 -2
- package/src/handlers/account-delete.ts +1 -1
- package/src/handlers/account-export.sqlite.test.ts +2 -0
- package/src/handlers/account-fanout.test.ts +1 -0
- package/src/handlers/organize-job.test.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/account-worker",
|
|
3
|
-
"version": "0.0.
|
|
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 {
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
38
|
-
// lazily so
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
package/src/deletion-port.ts
CHANGED
|
@@ -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, [
|
|
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(
|
|
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
|
});
|
|
@@ -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[] = [];
|