@remit/drizzle-service 0.0.46 → 0.0.48

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/drizzle-service",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,13 +1,3 @@
1
- /**
2
- * The repair against the shape a deployment actually runs: the committed
3
- * `CREATE TABLE` blocks, not a schema pushed from the drizzle table objects.
4
- *
5
- * What this has to hold, on a live database with no second copy of the data:
6
- * every name the harvest guard would keep survives, a name that says something
7
- * besides the address keeps that something, no row is removed, and a second run
8
- * writes nothing.
9
- */
10
-
11
1
  import assert from "node:assert/strict";
12
2
  import { after, before, describe, test } from "node:test";
13
3
  import { storedDisplayName } from "@remit/data-ports/display-name";
@@ -232,11 +222,6 @@ describe("rewriting display names that claim another address", () => {
232
222
  });
233
223
  });
234
224
 
235
- /**
236
- * The SQL in this repair narrows; it never decides. That only holds if no name
237
- * the rule rewrites can slip past the narrowing — the case where a planted name
238
- * would survive the sweep unseen.
239
- */
240
225
  describe("the SQL narrowing is a superset of the rule", () => {
241
226
  test("selects every name the rule rewrites", () => {
242
227
  const sqlite = new Database(":memory:");
@@ -1,29 +1,5 @@
1
1
  import { storedDisplayName } from "@remit/data-ports/display-name";
2
2
 
3
- /**
4
- * Rewriting the display names a spoofing sender already planted (issue #826).
5
- *
6
- * The harvest guard only decides what is stored from now on. Every name already
7
- * written stays live, and none of the three write paths repairs one on a
8
- * re-sync: `upsertAddress` refuses to overwrite a name with an empty one,
9
- * `upsertEnvelopeAddress` is `onConflictDoNothing`, and a ThreadMessage is
10
- * created once. On the instance that was hit there were 150 email-shaped
11
- * display names in `address` alone, 30 of them naming a different address.
12
- *
13
- * The decision is `storedDisplayName` and nothing else. Expressing it a second
14
- * time in SQL is what makes this dangerous: SQLite's `lower()` folds ASCII
15
- * where JS folds all of Unicode, and its `trim()` and a literal space in a GLOB
16
- * know only U+0020, so a SQL twin rewrites `Özcan@example.com` on
17
- * `özcan@example.com` and `foo\tbar@baz.com` on anything — names the guard
18
- * keeps, destroyed on a database holding the only copy. SQL narrows the scan
19
- * and never decides.
20
- *
21
- * This is a repair rather than a migration because a migration is SQL, and SQL
22
- * is exactly what must not hold the rule. It is convergent: a name the guard
23
- * would keep is never rewritten twice, so re-running it, or resuming it after a
24
- * crash part-way through, writes only what is left to write.
25
- */
26
-
27
3
  export interface DisplayNameRepairClient {
28
4
  all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
29
5
  run(sql: string, params: readonly unknown[]): Promise<number>;
@@ -31,53 +7,39 @@ export interface DisplayNameRepairClient {
31
7
 
32
8
  export type DisplayNameRepairMode = "check" | "repair";
33
9
 
34
- /**
35
- * The scan narrows to names that could carry an address at all: everything
36
- * `storedDisplayName` rewrites contains `x@y.zz`, so a row this misses cannot
37
- * be claiming anything. It is a filter, never the decision — the pairing is
38
- * pinned by a test that runs both halves over the same strings.
39
- */
40
10
  export const EMBEDDED_ADDRESS_LIKE = "%_@_%.__%";
41
11
 
42
12
  interface RepairSite {
43
13
  readonly table: string;
44
- readonly key: string;
45
- readonly name: string;
46
- readonly email: string;
47
- /** The compound the search path reads, where the table keeps one. */
48
- readonly compound?: string;
49
- /** What the write path stores for an absent name on this table. */
50
- readonly absent: "" | null;
14
+ readonly keyColumn: string;
15
+ readonly nameColumn: string;
16
+ readonly emailColumn: string;
17
+ readonly searchCompoundColumn?: string;
18
+ readonly storedWhenNameIsAbsent: "" | null;
51
19
  }
52
20
 
53
- /**
54
- * Every column an attacker-chosen name lands in. `address.display_name` is the
55
- * one autocomplete reads; `envelope_address.display_name` is the From line the
56
- * message header renders; `thread_message.from_name` is the sender label in the
57
- * message list and the text the search index tokenizes.
58
- */
59
21
  const SITES: readonly RepairSite[] = [
60
22
  {
61
23
  table: "address",
62
- key: "address_id",
63
- name: "display_name",
64
- email: "normalized_email",
65
- compound: "normalized_compound",
66
- absent: "",
24
+ keyColumn: "address_id",
25
+ nameColumn: "display_name",
26
+ emailColumn: "normalized_email",
27
+ searchCompoundColumn: "normalized_compound",
28
+ storedWhenNameIsAbsent: "",
67
29
  },
68
30
  {
69
31
  table: "envelope_address",
70
- key: "envelope_address_id",
71
- name: "display_name",
72
- email: "normalized_email",
73
- absent: "",
32
+ keyColumn: "envelope_address_id",
33
+ nameColumn: "display_name",
34
+ emailColumn: "normalized_email",
35
+ storedWhenNameIsAbsent: "",
74
36
  },
75
37
  {
76
38
  table: "thread_message",
77
- key: "thread_message_id",
78
- name: "from_name",
79
- email: "from_email",
80
- absent: null,
39
+ keyColumn: "thread_message_id",
40
+ nameColumn: "from_name",
41
+ emailColumn: "from_email",
42
+ storedWhenNameIsAbsent: null,
81
43
  },
82
44
  ];
83
45
 
@@ -110,44 +72,37 @@ const candidates = async (
110
72
  site: RepairSite,
111
73
  ): Promise<CandidateRow[]> => {
112
74
  const rows = await client.all(
113
- `SELECT ${site.key} AS id, ${site.name} AS name, ${site.email} AS email
75
+ `SELECT ${site.keyColumn} AS id, ${site.nameColumn} AS name, ${site.emailColumn} AS email
114
76
  FROM ${site.table}
115
- WHERE ${site.name} LIKE ?`,
77
+ WHERE ${site.nameColumn} LIKE ?`,
116
78
  [EMBEDDED_ADDRESS_LIKE],
117
79
  );
118
80
  return rows.filter(isCandidateRow);
119
81
  };
120
82
 
121
- /**
122
- * One row at a time, because each row keeps a different remainder. The set is
123
- * what a spoofing sender planted, not the table.
124
- */
125
83
  const rewrite = async (
126
84
  client: DisplayNameRepairClient,
127
85
  site: RepairSite,
128
86
  row: CandidateRow,
129
87
  stored: string,
130
88
  ): Promise<number> => {
131
- const columns = [`${site.name} = ?`];
132
- const params: unknown[] = [stored === "" ? site.absent : stored];
89
+ const columns = [`${site.nameColumn} = ?`];
90
+ const params: unknown[] = [
91
+ stored === "" ? site.storedWhenNameIsAbsent : stored,
92
+ ];
133
93
 
134
- if (site.compound) {
135
- columns.push(`${site.compound} = ?`);
94
+ if (site.searchCompoundColumn) {
95
+ columns.push(`${site.searchCompoundColumn} = ?`);
136
96
  params.push(`${stored.toLowerCase()} ${row.email ?? ""}`.trim());
137
97
  }
138
98
  params.push(row.id);
139
99
 
140
100
  return client.run(
141
- `UPDATE ${site.table} SET ${columns.join(", ")} WHERE ${site.key} = ?`,
101
+ `UPDATE ${site.table} SET ${columns.join(", ")} WHERE ${site.keyColumn} = ?`,
142
102
  params,
143
103
  );
144
104
  };
145
105
 
146
- /**
147
- * `check` writes nothing, so it can be pointed at a live instance; `repair`
148
- * runs the same scan and rewrites what it finds. One code path, so the report
149
- * can never describe a decision the repair does not make.
150
- */
151
106
  export const sweepDisplayNames = async (
152
107
  client: DisplayNameRepairClient,
153
108
  mode: DisplayNameRepairMode,
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The repair against the shape a deployment actually runs: the committed
3
+ * `CREATE TABLE` block, not a schema pushed from the drizzle table objects.
4
+ *
5
+ * What it has to hold on a live database: a message whose filing is still in
6
+ * flight is left alone, a stranded one becomes visible, no row is removed, and
7
+ * a second run writes nothing.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { after, before, describe, test } from "node:test";
12
+ import Database from "better-sqlite3";
13
+ import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
14
+ import {
15
+ STRANDED_AFTER_MILLIS,
16
+ type StrandedSentRepairClient,
17
+ sweepStrandedSentOutbox,
18
+ } from "./stranded-sent-outbox.js";
19
+
20
+ const clientOver = (sqlite: Database.Database): StrandedSentRepairClient => ({
21
+ all: async (sql, params) => sqlite.prepare(sql).all(...params),
22
+ run: async (sql, params) => sqlite.prepare(sql).run(...params).changes,
23
+ });
24
+
25
+ interface Row {
26
+ status: string;
27
+ last_error: string | null;
28
+ }
29
+
30
+ describe("outbox rows stranded at sent", () => {
31
+ let sqlite: Database.Database;
32
+
33
+ const insert = (
34
+ outboxMessageId: string,
35
+ status: string,
36
+ sentAt: number,
37
+ ): void => {
38
+ sqlite
39
+ .prepare(
40
+ `INSERT INTO outbox_message (
41
+ outbox_message_id, account_id, account_config_id, from_address,
42
+ to_addresses, cc_addresses, bcc_addresses, "references",
43
+ message_id_value, status, sent_at, created_at, updated_at
44
+ ) VALUES (?, 'acc', 'cfg', 'me@example.com', '["you@example.com"]',
45
+ '[]', '[]', '[]', 'mid@example.com', ?, ?, ?, ?)`,
46
+ )
47
+ .run(outboxMessageId, status, sentAt, sentAt, sentAt);
48
+ };
49
+
50
+ const read = (outboxMessageId: string): Row =>
51
+ sqlite
52
+ .prepare(
53
+ "SELECT status, last_error FROM outbox_message WHERE outbox_message_id = ?",
54
+ )
55
+ .get(outboxMessageId) as Row;
56
+
57
+ before(() => {
58
+ sqlite = new Database(":memory:");
59
+ sqlite.exec(
60
+ shippedTableDdl("0000_happy_roland_deschain", "outbox_message"),
61
+ );
62
+ });
63
+
64
+ after(() => {
65
+ sqlite.close();
66
+ });
67
+
68
+ test("makes a stranded sent message visible again", async () => {
69
+ const stranded = Date.now() - STRANDED_AFTER_MILLIS - 1000;
70
+ insert("stranded", "sent", stranded);
71
+
72
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
73
+
74
+ assert.equal(report.stranded, 1);
75
+ assert.equal(report.settled, 1);
76
+ const row = read("stranded");
77
+ assert.equal(row.status, "unfiled");
78
+ assert.match(String(row.last_error), /not filed/);
79
+ });
80
+
81
+ test("a second run writes nothing", async () => {
82
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
83
+
84
+ assert.equal(report.stranded, 0);
85
+ assert.equal(report.settled, 0);
86
+ });
87
+
88
+ test("leaves a filing that is still in flight alone", async () => {
89
+ insert("in-flight", "sent", Date.now());
90
+
91
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
92
+
93
+ assert.equal(report.stranded, 0);
94
+ assert.equal(read("in-flight").status, "sent");
95
+ });
96
+
97
+ test("touches no other status", async () => {
98
+ const old = Date.now() - STRANDED_AFTER_MILLIS - 1000;
99
+ insert("a-draft", "draft", old);
100
+ insert("a-failure", "failed", old);
101
+
102
+ await sweepStrandedSentOutbox(clientOver(sqlite), "repair");
103
+
104
+ assert.equal(read("a-draft").status, "draft");
105
+ assert.equal(read("a-failure").status, "failed");
106
+ });
107
+
108
+ test("check mode reports what it would do and writes nothing", async () => {
109
+ insert("also-stranded", "sent", Date.now() - STRANDED_AFTER_MILLIS - 1000);
110
+
111
+ const report = await sweepStrandedSentOutbox(clientOver(sqlite), "check");
112
+
113
+ assert.equal(report.stranded, 1);
114
+ assert.equal(report.settled, 0);
115
+ assert.equal(read("also-stranded").status, "sent");
116
+ });
117
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The repair for outbox rows stranded at `sent` (issue #824).
3
+ *
4
+ * A row holds `sent` only between SMTP accepting the message and the IMAP
5
+ * APPEND that files a copy in Sent, whose last act is to delete the row. Every
6
+ * way that APPEND could fail used to end in a silent return, so the row stayed
7
+ * at `sent` forever — and `sent` is the one non-draft status the Outbox list
8
+ * hides, on the assumption that the APPEND deletes it. The message was
9
+ * delivered, exists in no server folder, and appears in no view.
10
+ *
11
+ * The handler now settles those failures as `unfiled`. That reaches nothing
12
+ * already stranded: the events are long gone, and the rows are on instances
13
+ * that only an image update reaches. So the same settlement is applied here,
14
+ * once, at boot — the migrate one-shot is the only step every self-host
15
+ * instance runs before its app containers start (#281).
16
+ *
17
+ * The age bound is what separates a stranded row from an ordinary one. A
18
+ * message sent seconds before a restart has its APPEND event still queued, and
19
+ * that event settles the row itself once the workers come back; rewriting it
20
+ * here would take a filing that was about to succeed. An hour is far past any
21
+ * redrive budget, so a row older than that has no event coming.
22
+ *
23
+ * It is a status flip and an error string, on rows whose only other outcome is
24
+ * to stay invisible. Nothing is deleted and no message content is touched.
25
+ */
26
+
27
+ /**
28
+ * The smallest surface this needs, so the module imports no schema and no
29
+ * driver and can be driven by the migrator's `better-sqlite3` handle or a test.
30
+ */
31
+ export interface StrandedSentRepairClient {
32
+ all(sql: string, params: unknown[]): Promise<unknown[]>;
33
+ run(sql: string, params: unknown[]): Promise<number>;
34
+ }
35
+
36
+ export type StrandedSentRepairMode = "check" | "repair";
37
+
38
+ export type StrandedSentReport = {
39
+ readonly mode: StrandedSentRepairMode;
40
+ readonly stranded: number;
41
+ readonly settled: number;
42
+ };
43
+
44
+ /**
45
+ * One hour. A row younger than this may still have its APPEND event in the
46
+ * queue, and that event settles the row correctly on its own.
47
+ */
48
+ export const STRANDED_AFTER_MILLIS = 60 * 60 * 1000;
49
+
50
+ const STRANDED_SENT_REASON =
51
+ "Sent, but not filed: filing a copy in the Sent folder never completed. The message was delivered.";
52
+
53
+ const NOW_MILLIS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)";
54
+
55
+ const AGE = `coalesce(sent_at, updated_at) < ${NOW_MILLIS} - ?`;
56
+
57
+ const COUNT_SQL = `SELECT count(*) AS row_count
58
+ FROM outbox_message
59
+ WHERE status = 'sent' AND ${AGE}`;
60
+
61
+ const SETTLE_SQL = `UPDATE outbox_message
62
+ SET status = 'unfiled', last_error = ?
63
+ WHERE status = 'sent' AND ${AGE}`;
64
+
65
+ const countStranded = async (
66
+ client: StrandedSentRepairClient,
67
+ ): Promise<number> => {
68
+ const [row] = (await client.all(COUNT_SQL, [STRANDED_AFTER_MILLIS])) as {
69
+ row_count: number;
70
+ }[];
71
+ return row?.row_count ?? 0;
72
+ };
73
+
74
+ /**
75
+ * The UPDATE runs only when the count found rows. SQLite takes its exclusive
76
+ * write lock the moment an UPDATE begins, before it can know the WHERE matches
77
+ * nothing, and a lock the migrator cannot get inside its `busy_timeout` fails
78
+ * the migration and holds every gated service down. Zero is the steady state.
79
+ */
80
+ export const sweepStrandedSentOutbox = async (
81
+ client: StrandedSentRepairClient,
82
+ mode: StrandedSentRepairMode,
83
+ ): Promise<StrandedSentReport> => {
84
+ const stranded = await countStranded(client);
85
+ if (mode === "check" || stranded === 0) {
86
+ return { mode, stranded, settled: 0 };
87
+ }
88
+ const settled = await client.run(SETTLE_SQL, [
89
+ STRANDED_SENT_REASON,
90
+ STRANDED_AFTER_MILLIS,
91
+ ]);
92
+ return { mode, stranded, settled };
93
+ };
94
+
95
+ export const formatStrandedSentReport = (
96
+ report: StrandedSentReport,
97
+ ): string[] => {
98
+ if (report.stranded === 0) {
99
+ return ["No sent message is stranded in the outbox"];
100
+ }
101
+ if (report.mode === "check") {
102
+ return [
103
+ `${report.stranded} sent message(s) stranded in the outbox, would be marked unfiled`,
104
+ ];
105
+ }
106
+ return [
107
+ `${report.settled} of ${report.stranded} stranded sent message(s) marked unfiled`,
108
+ ];
109
+ };