@remit/backend 0.0.25 → 0.0.27
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 +1 -1
- package/src/derive/autoMoved.test.ts +104 -0
- package/src/derive/autoMoved.ts +36 -8
- package/src/handlers/index.ts +2 -0
- package/src/handlers/system-update.test.ts +286 -0
- package/src/handlers/system-update.ts +189 -0
- package/src/service/create-remit-client.ts +31 -0
- package/src/types.ts +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { MessageItem } from "@remit/data-ports";
|
|
4
|
+
import { PlacementAction, PlacementConfidence } from "@remit/domain-enums";
|
|
5
|
+
import { deriveAutoMoved } from "./autoMoved.js";
|
|
6
|
+
|
|
7
|
+
type AutoMovedInput = Pick<
|
|
8
|
+
MessageItem,
|
|
9
|
+
"movedByRemit" | "placementVerdict" | "filterMove"
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
const confidentVerdict = {
|
|
13
|
+
action: PlacementAction.MoveToInbox,
|
|
14
|
+
confidence: PlacementConfidence.Confident,
|
|
15
|
+
fromPlacement: "junk",
|
|
16
|
+
reasons: ["provider=spam", "dmarc=pass"],
|
|
17
|
+
dryRun: false,
|
|
18
|
+
decidedAt: 1_700_000_000_000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const filterMove = {
|
|
22
|
+
filterId: "flt-1",
|
|
23
|
+
sourceMailboxId: "mb-inbox",
|
|
24
|
+
destinationMailboxId: "mb-travel",
|
|
25
|
+
decidedAt: 1_700_000_000_000,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("deriveAutoMoved", () => {
|
|
29
|
+
it("returns undefined when the message was not moved by Remit", () => {
|
|
30
|
+
const message: AutoMovedInput = {
|
|
31
|
+
movedByRemit: false,
|
|
32
|
+
placementVerdict: confidentVerdict,
|
|
33
|
+
};
|
|
34
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("projects a standing-filter move to its mailbox ids and filter id", () => {
|
|
38
|
+
const message: AutoMovedInput = { movedByRemit: true, filterMove };
|
|
39
|
+
assert.deepEqual(deriveAutoMoved(message), {
|
|
40
|
+
fromMailboxId: "mb-inbox",
|
|
41
|
+
destinationMailboxId: "mb-travel",
|
|
42
|
+
filterId: "flt-1",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("never leaks the classifier action/fromPlacement for a filter move", () => {
|
|
47
|
+
const result = deriveAutoMoved({ movedByRemit: true, filterMove });
|
|
48
|
+
assert.equal(result?.action, undefined);
|
|
49
|
+
assert.equal(result?.fromPlacement, undefined);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("a filter move outranks a co-present placement verdict", () => {
|
|
53
|
+
// Both a filter move and a classifier verdict can be recorded; the filter's
|
|
54
|
+
// exclusive move is what ultimately placed the message (RFC 034 Dec. 3.1).
|
|
55
|
+
const message: AutoMovedInput = {
|
|
56
|
+
movedByRemit: true,
|
|
57
|
+
filterMove,
|
|
58
|
+
placementVerdict: confidentVerdict,
|
|
59
|
+
};
|
|
60
|
+
assert.deepEqual(deriveAutoMoved(message), {
|
|
61
|
+
fromMailboxId: "mb-inbox",
|
|
62
|
+
destinationMailboxId: "mb-travel",
|
|
63
|
+
filterId: "flt-1",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("projects a confident classifier move to action + fromPlacement", () => {
|
|
68
|
+
const message: AutoMovedInput = {
|
|
69
|
+
movedByRemit: true,
|
|
70
|
+
placementVerdict: confidentVerdict,
|
|
71
|
+
};
|
|
72
|
+
assert.deepEqual(deriveAutoMoved(message), {
|
|
73
|
+
action: PlacementAction.MoveToInbox,
|
|
74
|
+
fromPlacement: "junk",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("drops an unsure classifier verdict", () => {
|
|
79
|
+
const message: AutoMovedInput = {
|
|
80
|
+
movedByRemit: true,
|
|
81
|
+
placementVerdict: {
|
|
82
|
+
...confidentVerdict,
|
|
83
|
+
confidence: PlacementConfidence.Unsure,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("drops a dry-run classifier verdict", () => {
|
|
90
|
+
const message: AutoMovedInput = {
|
|
91
|
+
movedByRemit: true,
|
|
92
|
+
placementVerdict: { ...confidentVerdict, dryRun: true },
|
|
93
|
+
};
|
|
94
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does not crash on legacy verdict-less, filter-less moved data", () => {
|
|
98
|
+
// A message moved before either the verdict or the filter marker existed:
|
|
99
|
+
// movedByRemit is set but nothing is derivable. Must return undefined, not
|
|
100
|
+
// throw (issue #223 back-compat).
|
|
101
|
+
const message: AutoMovedInput = { movedByRemit: true };
|
|
102
|
+
assert.equal(deriveAutoMoved(message), undefined);
|
|
103
|
+
});
|
|
104
|
+
});
|
package/src/derive/autoMoved.ts
CHANGED
|
@@ -3,19 +3,47 @@ import type { MessageItem } from "@remit/data-ports";
|
|
|
3
3
|
import { PlacementConfidence } from "@remit/domain-enums";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Project a Message's internal
|
|
7
|
-
* shape. Present only for a real, in-effect auto-move
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* Project a Message's internal auto-move state to the public `AutoMovedInfo`
|
|
7
|
+
* shape. Present only for a real, in-effect auto-move; absent otherwise, so a
|
|
8
|
+
* message synced before either mechanism existed (no `filterMove`, no
|
|
9
|
+
* `placementVerdict`) projects nothing and renders no badge.
|
|
10
|
+
*
|
|
11
|
+
* Two mechanisms move mail and both raise `movedByRemit`:
|
|
12
|
+
*
|
|
13
|
+
* 1. A standing filter (RFC 034) records a `filterMove` marker naming the
|
|
14
|
+
* source folder, the destination folder, and the filter. It has no
|
|
15
|
+
* classifier direction/role, so the projection carries mailbox ids
|
|
16
|
+
* (`fromMailboxId`/`destinationMailboxId`) and the `filterId` for the
|
|
17
|
+
* Settings link — an arbitrary destination the Inbox/Junk-shaped `action`
|
|
18
|
+
* cannot name.
|
|
19
|
+
* 2. The Tier 0 classifier records a `placementVerdict`. Its `action`
|
|
20
|
+
* (MoveToInbox/MoveToJunk) and `fromPlacement` (inbox/junk/other) are the
|
|
21
|
+
* only fields that cross to the wire; confidence, dryRun, decidedAt and
|
|
22
|
+
* reasons stay internal.
|
|
23
|
+
*
|
|
24
|
+
* The filter marker outranks the verdict check: a matched filter's move is
|
|
25
|
+
* exclusive and outranks the classifier's placement move at index time (RFC 034
|
|
26
|
+
* Decision 3.1), so a message carrying both was ultimately moved by the filter.
|
|
27
|
+
* The client derives everything else (whether the move is still in effect, the
|
|
28
|
+
* undo target) from these fields plus current placement.
|
|
13
29
|
*/
|
|
14
30
|
export const deriveAutoMoved = (
|
|
15
|
-
message: Pick<
|
|
31
|
+
message: Pick<
|
|
32
|
+
MessageItem,
|
|
33
|
+
"movedByRemit" | "placementVerdict" | "filterMove"
|
|
34
|
+
>,
|
|
16
35
|
): AutoMovedInfo | undefined => {
|
|
17
36
|
if (message.movedByRemit !== true) return undefined;
|
|
18
37
|
|
|
38
|
+
const filterMove = message.filterMove;
|
|
39
|
+
if (filterMove) {
|
|
40
|
+
return {
|
|
41
|
+
fromMailboxId: filterMove.sourceMailboxId,
|
|
42
|
+
destinationMailboxId: filterMove.destinationMailboxId,
|
|
43
|
+
filterId: filterMove.filterId,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
19
47
|
const verdict = message.placementVerdict;
|
|
20
48
|
if (!verdict) return undefined;
|
|
21
49
|
if (verdict.confidence !== PlacementConfidence.Confident) return undefined;
|
package/src/handlers/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { OrganizeJobDetailOperations, OrganizeOperations } from "./organize.js";
|
|
|
16
16
|
import { OutboxDetailOperations, OutboxOperations } from "./outbox.js";
|
|
17
17
|
import { SemanticSearchOperations } from "./search.js";
|
|
18
18
|
import { SyncOperations } from "./sync.js";
|
|
19
|
+
import { SystemOperations } from "./system-update.js";
|
|
19
20
|
import { ThreadDetailOperations, ThreadOperations } from "./thread.js";
|
|
20
21
|
import { UnifiedThreadOperations } from "./unified-threads.js";
|
|
21
22
|
|
|
@@ -23,6 +24,7 @@ import { UnifiedThreadOperations } from "./unified-threads.js";
|
|
|
23
24
|
export const handlers: Record<OperationIds, OperationHandler<any>> = {
|
|
24
25
|
...MeOperations,
|
|
25
26
|
...ConfigOperations,
|
|
27
|
+
...SystemOperations,
|
|
26
28
|
...AccountOperations,
|
|
27
29
|
...AccountDetailOperations,
|
|
28
30
|
...MicrosoftOAuthOperations,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { createInstanceOwnerStore } from "@remit/auth-service";
|
|
13
|
+
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
14
|
+
import Database from "better-sqlite3";
|
|
15
|
+
import type { Context } from "openapi-backend";
|
|
16
|
+
import { SystemOperations } from "./system-update.js";
|
|
17
|
+
|
|
18
|
+
const tmpRoot = join(
|
|
19
|
+
fileURLToPath(new URL(".", import.meta.url)),
|
|
20
|
+
"..",
|
|
21
|
+
"..",
|
|
22
|
+
".tmp",
|
|
23
|
+
"system-update",
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const readMigrationSql = (relativePath: string): string =>
|
|
27
|
+
readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
|
|
28
|
+
|
|
29
|
+
const authMigrationSql = readMigrationSql(
|
|
30
|
+
"../../../../deploy/vps/migrations-sqlite/auth/0000_rainy_iron_monger.sql",
|
|
31
|
+
);
|
|
32
|
+
const metaMigrationSql = readMigrationSql(
|
|
33
|
+
"../../../../deploy/vps/migrations-sqlite/meta/0000_demonic_lilith.sql",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const OWNER = "the-owner";
|
|
37
|
+
const MANIFEST_URL = "https://updates.example.com/stable.json";
|
|
38
|
+
|
|
39
|
+
let dbPath: string;
|
|
40
|
+
let controlDir: string;
|
|
41
|
+
|
|
42
|
+
const okState = {
|
|
43
|
+
currentVersion: "v1.4.1",
|
|
44
|
+
check: {
|
|
45
|
+
status: "ok",
|
|
46
|
+
lastCheckedAt: "2026-07-20T08:00:00Z",
|
|
47
|
+
latestVersion: "v1.5.0",
|
|
48
|
+
publishedAt: "2026-07-18T09:00:00Z",
|
|
49
|
+
summary: "Faster search.",
|
|
50
|
+
releaseNotesUrl: "https://example.com/notes",
|
|
51
|
+
updateAvailable: true,
|
|
52
|
+
},
|
|
53
|
+
run: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const writeState = (state: unknown): void => {
|
|
57
|
+
writeFileSync(join(controlDir, "state.json"), JSON.stringify(state));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
before(async () => {
|
|
61
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
62
|
+
dbPath = join(mkdtempSync(join(tmpRoot, "db-")), "app.db");
|
|
63
|
+
const sqlite = new Database(dbPath);
|
|
64
|
+
for (const sql of [authMigrationSql, metaMigrationSql]) {
|
|
65
|
+
for (const statement of sql.split("--> statement-breakpoint")) {
|
|
66
|
+
sqlite.exec(statement);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
sqlite
|
|
70
|
+
.prepare(
|
|
71
|
+
"INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
|
|
72
|
+
)
|
|
73
|
+
.run(OWNER, OWNER, "owner@example.com", 1000, 1000);
|
|
74
|
+
sqlite.close();
|
|
75
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
76
|
+
process.env.SQLITE_DB_PATH = dbPath;
|
|
77
|
+
const store = await createInstanceOwnerStore({
|
|
78
|
+
provider: "sqlite",
|
|
79
|
+
connectionString: dbPath,
|
|
80
|
+
});
|
|
81
|
+
await store.claimIfUnclaimed(OWNER);
|
|
82
|
+
await store.close();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
after(() => {
|
|
86
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
controlDir = mkdtempSync(join(tmpRoot, "control-"));
|
|
91
|
+
process.env.REMIT_UPDATE_CONTROL_DIR = controlDir;
|
|
92
|
+
process.env.REMIT_UPDATE_MANIFEST_URL = MANIFEST_URL;
|
|
93
|
+
process.env.REMIT_TAG = "v1.4.1";
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
delete process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
98
|
+
delete process.env.REMIT_UPDATE_CONTROL_DIR;
|
|
99
|
+
delete process.env.REMIT_TAG;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const buildEvent = (sub?: string): APIGatewayProxyEvent =>
|
|
103
|
+
({
|
|
104
|
+
headers: {},
|
|
105
|
+
requestContext: sub ? { authorizer: { claims: { sub } } } : {},
|
|
106
|
+
}) as unknown as APIGatewayProxyEvent;
|
|
107
|
+
|
|
108
|
+
const postContext = (targetVersion: string): Context =>
|
|
109
|
+
({ request: { requestBody: { targetVersion } } }) as unknown as Context;
|
|
110
|
+
|
|
111
|
+
const getSystemUpdate =
|
|
112
|
+
SystemOperations.SystemOperations_getSystemUpdate as unknown as (
|
|
113
|
+
context: Context,
|
|
114
|
+
event: APIGatewayProxyEvent,
|
|
115
|
+
) => Promise<unknown>;
|
|
116
|
+
|
|
117
|
+
const applySystemUpdate =
|
|
118
|
+
SystemOperations.SystemOperations_applySystemUpdate as unknown as (
|
|
119
|
+
context: Context,
|
|
120
|
+
event: APIGatewayProxyEvent,
|
|
121
|
+
) => Promise<unknown>;
|
|
122
|
+
|
|
123
|
+
const getUpdate = (event: APIGatewayProxyEvent) =>
|
|
124
|
+
getSystemUpdate({} as unknown as Context, event);
|
|
125
|
+
|
|
126
|
+
const applyUpdate = (targetVersion: string, event: APIGatewayProxyEvent) =>
|
|
127
|
+
applySystemUpdate(postContext(targetVersion), event);
|
|
128
|
+
|
|
129
|
+
const hasStatus = (value: unknown, code: number): boolean =>
|
|
130
|
+
typeof value === "object" &&
|
|
131
|
+
value !== null &&
|
|
132
|
+
(value as { statusCode?: number }).statusCode === code;
|
|
133
|
+
|
|
134
|
+
describe("GET /system/update", () => {
|
|
135
|
+
it("returns 404 when no manifest URL is configured", async () => {
|
|
136
|
+
delete process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
137
|
+
|
|
138
|
+
const result = await getUpdate(buildEvent(OWNER));
|
|
139
|
+
|
|
140
|
+
assert.ok(hasStatus(result, 404));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns 403 for a non-owner", async () => {
|
|
144
|
+
const result = await getUpdate(buildEvent("someone-else"));
|
|
145
|
+
|
|
146
|
+
assert.ok(hasStatus(result, 403));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("returns 200 with the state file verbatim", async () => {
|
|
150
|
+
writeState(okState);
|
|
151
|
+
|
|
152
|
+
const result = await getUpdate(buildEvent(OWNER));
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(result, okState);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns a disabled, empty resource when no state file exists", async () => {
|
|
158
|
+
const result = await getUpdate(buildEvent(OWNER));
|
|
159
|
+
|
|
160
|
+
assert.deepEqual(result, {
|
|
161
|
+
currentVersion: "v1.4.1",
|
|
162
|
+
check: { status: "disabled" },
|
|
163
|
+
run: null,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("returns 200 with the run block untouched when the check has failed", async () => {
|
|
168
|
+
const run = {
|
|
169
|
+
runId: "run-1",
|
|
170
|
+
fromVersion: "v1.3.0",
|
|
171
|
+
targetVersion: "v1.4.1",
|
|
172
|
+
phase: "committing",
|
|
173
|
+
outcome: "succeeded",
|
|
174
|
+
startedAt: "2026-07-19T08:00:00Z",
|
|
175
|
+
updatedAt: "2026-07-19T08:05:00Z",
|
|
176
|
+
message: "Update installed.",
|
|
177
|
+
logCommand: "remit logs backend",
|
|
178
|
+
};
|
|
179
|
+
writeState({
|
|
180
|
+
currentVersion: "v1.4.1",
|
|
181
|
+
check: {
|
|
182
|
+
status: "failed",
|
|
183
|
+
lastCheckedAt: "2026-07-20T08:00:00Z",
|
|
184
|
+
error: "The update manifest could not be fetched.",
|
|
185
|
+
},
|
|
186
|
+
run,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const result = (await getUpdate(buildEvent(OWNER))) as {
|
|
190
|
+
check: { status: string };
|
|
191
|
+
run: unknown;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
assert.equal(result.check.status, "failed");
|
|
195
|
+
assert.deepEqual(result.run, run);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("POST /system/update", () => {
|
|
200
|
+
it("returns 404 when no manifest URL is configured", async () => {
|
|
201
|
+
delete process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
202
|
+
|
|
203
|
+
const result = await applyUpdate("v1.5.0", buildEvent(OWNER));
|
|
204
|
+
|
|
205
|
+
assert.ok(hasStatus(result, 404));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("returns 403 for a non-owner", async () => {
|
|
209
|
+
writeState(okState);
|
|
210
|
+
|
|
211
|
+
const result = await applyUpdate("v1.5.0", buildEvent("someone-else"));
|
|
212
|
+
|
|
213
|
+
assert.ok(hasStatus(result, 403));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("returns 409 when a run is already in flight", async () => {
|
|
217
|
+
writeState({
|
|
218
|
+
...okState,
|
|
219
|
+
run: {
|
|
220
|
+
runId: "run-9",
|
|
221
|
+
fromVersion: "v1.4.1",
|
|
222
|
+
targetVersion: "v1.5.0",
|
|
223
|
+
phase: "verifying",
|
|
224
|
+
outcome: null,
|
|
225
|
+
startedAt: "2026-07-20T08:01:00Z",
|
|
226
|
+
updatedAt: "2026-07-20T08:04:00Z",
|
|
227
|
+
message: "Waiting for the new version to answer.",
|
|
228
|
+
logCommand: "remit logs backend",
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
|
|
233
|
+
statusCode: 409,
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns 422 when the target is not the checked version", async () => {
|
|
238
|
+
writeState(okState);
|
|
239
|
+
|
|
240
|
+
await assert.rejects(applyUpdate("v9.9.9", buildEvent(OWNER)), {
|
|
241
|
+
statusCode: 422,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("returns 422 when no check has ever succeeded", async () => {
|
|
246
|
+
await assert.rejects(applyUpdate("v1.5.0", buildEvent(OWNER)), {
|
|
247
|
+
statusCode: 422,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("returns 202 with a run block carrying a fresh runId", async () => {
|
|
252
|
+
writeState(okState);
|
|
253
|
+
|
|
254
|
+
const result = (await applyUpdate("v1.5.0", buildEvent(OWNER))) as {
|
|
255
|
+
statusCode: number;
|
|
256
|
+
body: { run: { runId: string; targetVersion: string; outcome: null } };
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
assert.equal(result.statusCode, 202);
|
|
260
|
+
assert.equal(result.body.run.targetVersion, "v1.5.0");
|
|
261
|
+
assert.equal(result.body.run.outcome, null);
|
|
262
|
+
assert.ok(result.body.run.runId.length > 0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("writes request.json with exactly the four seam fields and nothing else", async () => {
|
|
266
|
+
writeState(okState);
|
|
267
|
+
|
|
268
|
+
await applyUpdate("v1.5.0", buildEvent(OWNER));
|
|
269
|
+
|
|
270
|
+
const request = JSON.parse(
|
|
271
|
+
readFileSync(join(controlDir, "request.json"), "utf8"),
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
assert.deepEqual(Object.keys(request).sort(), [
|
|
275
|
+
"requestedAt",
|
|
276
|
+
"requestedBy",
|
|
277
|
+
"runId",
|
|
278
|
+
"targetVersion",
|
|
279
|
+
]);
|
|
280
|
+
assert.equal(request.targetVersion, "v1.5.0");
|
|
281
|
+
assert.equal(request.requestedBy, OWNER);
|
|
282
|
+
for (const forbidden of ["registry", "image", "digest"]) {
|
|
283
|
+
assert.ok(!(forbidden in request));
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
SystemUpdateResponse,
|
|
6
|
+
SystemUpdateRun,
|
|
7
|
+
} from "@remit/api-openapi-types";
|
|
8
|
+
import { ConflictError, HTTPError } from "@remit/data-ports/errors";
|
|
9
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
10
|
+
import type { Context } from "openapi-backend";
|
|
11
|
+
import { getSubFromEvent } from "../auth.js";
|
|
12
|
+
import type { OperationHandler, SystemOperationIds } from "../types.js";
|
|
13
|
+
import { requireInstanceOwner } from "./owner-guard.js";
|
|
14
|
+
|
|
15
|
+
class TargetVersionMismatchError extends HTTPError {
|
|
16
|
+
name = "TargetVersionMismatchError";
|
|
17
|
+
statusCode = 422;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CONTROL_DIR = "/data/control";
|
|
21
|
+
|
|
22
|
+
const controlDir = (): string =>
|
|
23
|
+
process.env.REMIT_UPDATE_CONTROL_DIR ?? DEFAULT_CONTROL_DIR;
|
|
24
|
+
|
|
25
|
+
const manifestUrl = (): string | undefined =>
|
|
26
|
+
process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
27
|
+
|
|
28
|
+
const currentVersion = (): string => process.env.REMIT_TAG ?? "unknown";
|
|
29
|
+
|
|
30
|
+
const notFound = (): APIGatewayProxyResult => ({
|
|
31
|
+
statusCode: 404,
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ message: "Not found" }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
|
|
38
|
+
* which the hosted deployment leaves unset. Both endpoints answer 404 in that
|
|
39
|
+
* case — before the owner guard, so a probe cannot tell an off surface from a
|
|
40
|
+
* forbidden one.
|
|
41
|
+
*/
|
|
42
|
+
const guardManifestConfigured = (): APIGatewayProxyResult | null =>
|
|
43
|
+
manifestUrl() ? null : notFound();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read `state.json` off the control volume. A missing file is an instance that
|
|
47
|
+
* has never checked or updated (RFC 037 D8), not an error, so it reads as
|
|
48
|
+
* `null`; the caller renders the disabled/empty resource. Any other read or
|
|
49
|
+
* parse failure propagates.
|
|
50
|
+
*/
|
|
51
|
+
const readState = (): SystemUpdateResponse | null => {
|
|
52
|
+
let raw: string;
|
|
53
|
+
try {
|
|
54
|
+
raw = readFileSync(join(controlDir(), "state.json"), "utf8");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return JSON.parse(raw) as SystemUpdateResponse;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const emptyResource = (): SystemUpdateResponse => ({
|
|
63
|
+
currentVersion: currentVersion(),
|
|
64
|
+
check: { status: "disabled" },
|
|
65
|
+
run: null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const isRunInFlight = (run: SystemUpdateRun | null | undefined): boolean =>
|
|
69
|
+
run != null && run.outcome === null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The version the caller may install is the one the last check reported. It
|
|
73
|
+
* exists only on a successful check; a disabled or failed check offers nothing
|
|
74
|
+
* to match, so any target is a mismatch.
|
|
75
|
+
*/
|
|
76
|
+
const checkedVersion = (
|
|
77
|
+
state: SystemUpdateResponse | null,
|
|
78
|
+
): string | undefined =>
|
|
79
|
+
state?.check.status === "ok" ? state.check.latestVersion : undefined;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Write `request.json` atomically with exactly the four fields the seam accepts
|
|
83
|
+
* (#133). The updater resolves the registry and every image reference from the
|
|
84
|
+
* manifest it fetches itself; a request naming any of those is rejected whole,
|
|
85
|
+
* so the backend never writes one. The temp file sits in the same directory as
|
|
86
|
+
* the target for the rename to be atomic on one filesystem.
|
|
87
|
+
*/
|
|
88
|
+
const writeRequest = (request: {
|
|
89
|
+
runId: string;
|
|
90
|
+
targetVersion: string;
|
|
91
|
+
requestedAt: string;
|
|
92
|
+
requestedBy: string;
|
|
93
|
+
}): void => {
|
|
94
|
+
const dir = controlDir();
|
|
95
|
+
const tmp = join(dir, `.request.json.${request.runId}.tmp`);
|
|
96
|
+
writeFileSync(tmp, JSON.stringify(request), { mode: 0o644 });
|
|
97
|
+
renameSync(tmp, join(dir, "request.json"));
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The resource returned by the POST. The updater has not yet written the
|
|
102
|
+
* authoritative run — it polls the seam — so this bootstraps the run block with
|
|
103
|
+
* the id just requested and the first phase, giving the client a `runId` to
|
|
104
|
+
* persist and poll (RFC 037 D9). The updater's own `state.json` supersedes it on
|
|
105
|
+
* the next read.
|
|
106
|
+
*/
|
|
107
|
+
const requestedResource = (
|
|
108
|
+
state: SystemUpdateResponse | null,
|
|
109
|
+
runId: string,
|
|
110
|
+
targetVersion: string,
|
|
111
|
+
requestedAt: string,
|
|
112
|
+
): SystemUpdateResponse => {
|
|
113
|
+
const from = state?.currentVersion ?? currentVersion();
|
|
114
|
+
return {
|
|
115
|
+
currentVersion: from,
|
|
116
|
+
check: state?.check ?? { status: "disabled" },
|
|
117
|
+
run: {
|
|
118
|
+
runId,
|
|
119
|
+
fromVersion: from,
|
|
120
|
+
targetVersion,
|
|
121
|
+
phase: "checking",
|
|
122
|
+
outcome: null,
|
|
123
|
+
startedAt: requestedAt,
|
|
124
|
+
updatedAt: requestedAt,
|
|
125
|
+
message: "The update has been requested.",
|
|
126
|
+
logCommand: "remit logs updater",
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const SystemOperations: Record<
|
|
132
|
+
SystemOperationIds,
|
|
133
|
+
OperationHandler<SystemOperationIds>
|
|
134
|
+
> = {
|
|
135
|
+
SystemOperations_getSystemUpdate: async (
|
|
136
|
+
_context: Context,
|
|
137
|
+
...args: unknown[]
|
|
138
|
+
): Promise<SystemUpdateResponse | APIGatewayProxyResult> => {
|
|
139
|
+
const offSurface = guardManifestConfigured();
|
|
140
|
+
if (offSurface) return offSurface;
|
|
141
|
+
|
|
142
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
143
|
+
const forbidden = await requireInstanceOwner(event);
|
|
144
|
+
if (forbidden) return forbidden;
|
|
145
|
+
|
|
146
|
+
return readState() ?? emptyResource();
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
SystemOperations_applySystemUpdate: async (
|
|
150
|
+
context: Context,
|
|
151
|
+
...args: unknown[]
|
|
152
|
+
): Promise<SystemUpdateResponse | APIGatewayProxyResult> => {
|
|
153
|
+
const offSurface = guardManifestConfigured();
|
|
154
|
+
if (offSurface) return offSurface;
|
|
155
|
+
|
|
156
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
157
|
+
const forbidden = await requireInstanceOwner(event);
|
|
158
|
+
if (forbidden) return forbidden;
|
|
159
|
+
|
|
160
|
+
const { targetVersion } = context.request.requestBody as {
|
|
161
|
+
targetVersion: string;
|
|
162
|
+
};
|
|
163
|
+
const state = readState();
|
|
164
|
+
|
|
165
|
+
if (isRunInFlight(state?.run)) {
|
|
166
|
+
throw new ConflictError("An update is already in progress.");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (targetVersion !== checkedVersion(state)) {
|
|
170
|
+
throw new TargetVersionMismatchError(
|
|
171
|
+
"targetVersion does not match the latest checked release.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const runId = randomUUID();
|
|
176
|
+
const requestedAt = new Date().toISOString();
|
|
177
|
+
writeRequest({
|
|
178
|
+
runId,
|
|
179
|
+
targetVersion,
|
|
180
|
+
requestedAt,
|
|
181
|
+
requestedBy: getSubFromEvent(event) ?? "",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
statusCode: 202,
|
|
186
|
+
body: requestedResource(state, runId, targetVersion, requestedAt),
|
|
187
|
+
} as unknown as APIGatewayProxyResult;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
MailboxQueueService,
|
|
34
34
|
MessageMoveService,
|
|
35
35
|
OutboxQueueService,
|
|
36
|
+
PlacementMoveService,
|
|
36
37
|
} from "@remit/mailbox-service";
|
|
37
38
|
import { createSearchService, type SearchService } from "@remit/search-service";
|
|
38
39
|
import {
|
|
@@ -287,6 +288,34 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
|
|
|
287
288
|
bodySyncQueue,
|
|
288
289
|
} = deps;
|
|
289
290
|
|
|
291
|
+
// The read-path body backfill (describeMessage / getRawMessage →
|
|
292
|
+
// fetchAndGetBody) materializes a body that was never sync-synced, and must
|
|
293
|
+
// run the account's standing filters the same way the imap-worker's sync path
|
|
294
|
+
// does. Without this, a message a user opens before background body-sync is
|
|
295
|
+
// classified but never filter-evaluated — and once its body is stored the
|
|
296
|
+
// filter-capable sync path skips it, so an explicit standing filter silently
|
|
297
|
+
// never fires (issue #223). Only the explicit user-rule half is wired here;
|
|
298
|
+
// heuristic classifier placement stays on the sync path. Wired like
|
|
299
|
+
// `sync-message-body.ts`, over the message-management queue's local-first
|
|
300
|
+
// PlacementMoveService; filters stay off when that queue is unset.
|
|
301
|
+
const placementMoveQueueUrl = process.env.SQS_QUEUE_URL_MESSAGE_MGMT;
|
|
302
|
+
const placementMoveService = placementMoveQueueUrl
|
|
303
|
+
? new PlacementMoveService({
|
|
304
|
+
messageService: repositories.message,
|
|
305
|
+
threadMessageService: repositories.threadMessage,
|
|
306
|
+
markerService: repositories.placementMove,
|
|
307
|
+
sqsQueueUrl: placementMoveQueueUrl,
|
|
308
|
+
})
|
|
309
|
+
: undefined;
|
|
310
|
+
const filterConfig = placementMoveService
|
|
311
|
+
? {
|
|
312
|
+
filterService: repositories.filter,
|
|
313
|
+
filterAnchorService: repositories.filterAnchor,
|
|
314
|
+
messageLabelService: repositories.messageLabel,
|
|
315
|
+
placementMoveService,
|
|
316
|
+
}
|
|
317
|
+
: undefined;
|
|
318
|
+
|
|
290
319
|
const bodySync = new BodySyncService(
|
|
291
320
|
repositories.message,
|
|
292
321
|
storage,
|
|
@@ -294,6 +323,8 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
|
|
|
294
323
|
repositories.address,
|
|
295
324
|
repositories.envelope,
|
|
296
325
|
logger,
|
|
326
|
+
undefined,
|
|
327
|
+
filterConfig,
|
|
297
328
|
);
|
|
298
329
|
|
|
299
330
|
const flagPushService = new FlagPushService({
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type OperationIds =
|
|
|
12
12
|
| "MeOperations_getExport"
|
|
13
13
|
| "MeOperations_listQuarantine"
|
|
14
14
|
| "ConfigOperations_getConfig"
|
|
15
|
+
| "SystemOperations_getSystemUpdate"
|
|
16
|
+
| "SystemOperations_applySystemUpdate"
|
|
15
17
|
| "AccountOperations_createAccount"
|
|
16
18
|
| "AccountOperations_testConnection"
|
|
17
19
|
| "AccountDetailOperations_updateAccount"
|
|
@@ -60,6 +62,8 @@ export type MeOperationIds = MatchPrefix<"MeOperations_", OperationIds>;
|
|
|
60
62
|
|
|
61
63
|
export type ConfigOperationIds = MatchPrefix<"ConfigOperations_", OperationIds>;
|
|
62
64
|
|
|
65
|
+
export type SystemOperationIds = MatchPrefix<"SystemOperations_", OperationIds>;
|
|
66
|
+
|
|
63
67
|
export type AccountOperationIds = MatchPrefix<
|
|
64
68
|
"AccountOperations_",
|
|
65
69
|
OperationIds
|