@remit/backend 0.0.29 → 0.0.31
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
|
@@ -9,9 +9,7 @@ import {
|
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { createInstanceOwnerStore } from "@remit/auth-service";
|
|
13
12
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
14
|
-
import Database from "better-sqlite3";
|
|
15
13
|
import type { Context } from "openapi-backend";
|
|
16
14
|
import { SystemOperations } from "./system-update.js";
|
|
17
15
|
|
|
@@ -23,20 +21,9 @@ const tmpRoot = join(
|
|
|
23
21
|
"system-update",
|
|
24
22
|
);
|
|
25
23
|
|
|
26
|
-
const
|
|
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";
|
|
24
|
+
const USER = "any-signed-in-user";
|
|
37
25
|
const MANIFEST_URL = "https://updates.example.com/stable.json";
|
|
38
26
|
|
|
39
|
-
let dbPath: string;
|
|
40
27
|
let controlDir: string;
|
|
41
28
|
|
|
42
29
|
const okState = {
|
|
@@ -57,29 +44,8 @@ const writeState = (state: unknown): void => {
|
|
|
57
44
|
writeFileSync(join(controlDir, "state.json"), JSON.stringify(state));
|
|
58
45
|
};
|
|
59
46
|
|
|
60
|
-
before(
|
|
47
|
+
before(() => {
|
|
61
48
|
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
49
|
});
|
|
84
50
|
|
|
85
51
|
after(() => {
|
|
@@ -135,27 +101,37 @@ describe("GET /system/update", () => {
|
|
|
135
101
|
it("returns 404 when no manifest URL is configured", async () => {
|
|
136
102
|
delete process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
137
103
|
|
|
138
|
-
const result = await getUpdate(buildEvent(
|
|
104
|
+
const result = await getUpdate(buildEvent(USER));
|
|
139
105
|
|
|
140
106
|
assert.ok(hasStatus(result, 404));
|
|
141
107
|
});
|
|
142
108
|
|
|
143
|
-
it("returns
|
|
144
|
-
|
|
109
|
+
it("returns 401 when the caller is not authenticated", async () => {
|
|
110
|
+
writeState(okState);
|
|
111
|
+
|
|
112
|
+
const result = await getUpdate(buildEvent());
|
|
113
|
+
|
|
114
|
+
assert.ok(hasStatus(result, 401));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns 200 for any authenticated caller", async () => {
|
|
118
|
+
writeState(okState);
|
|
119
|
+
|
|
120
|
+
const result = await getUpdate(buildEvent("another-signed-in-user"));
|
|
145
121
|
|
|
146
|
-
assert.
|
|
122
|
+
assert.deepEqual(result, okState);
|
|
147
123
|
});
|
|
148
124
|
|
|
149
125
|
it("returns 200 with the state file verbatim", async () => {
|
|
150
126
|
writeState(okState);
|
|
151
127
|
|
|
152
|
-
const result = await getUpdate(buildEvent(
|
|
128
|
+
const result = await getUpdate(buildEvent(USER));
|
|
153
129
|
|
|
154
130
|
assert.deepEqual(result, okState);
|
|
155
131
|
});
|
|
156
132
|
|
|
157
133
|
it("returns a disabled, empty resource when no state file exists", async () => {
|
|
158
|
-
const result = await getUpdate(buildEvent(
|
|
134
|
+
const result = await getUpdate(buildEvent(USER));
|
|
159
135
|
|
|
160
136
|
assert.deepEqual(result, {
|
|
161
137
|
currentVersion: "v1.4.1",
|
|
@@ -186,7 +162,7 @@ describe("GET /system/update", () => {
|
|
|
186
162
|
run,
|
|
187
163
|
});
|
|
188
164
|
|
|
189
|
-
const result = (await getUpdate(buildEvent(
|
|
165
|
+
const result = (await getUpdate(buildEvent(USER))) as {
|
|
190
166
|
check: { status: string };
|
|
191
167
|
run: unknown;
|
|
192
168
|
};
|
|
@@ -203,7 +179,7 @@ describe("GET /system/update", () => {
|
|
|
203
179
|
run: null,
|
|
204
180
|
});
|
|
205
181
|
|
|
206
|
-
const result = (await getUpdate(buildEvent(
|
|
182
|
+
const result = (await getUpdate(buildEvent(USER))) as {
|
|
207
183
|
currentSchemaVersion?: number;
|
|
208
184
|
check: { schemaVersion?: number };
|
|
209
185
|
};
|
|
@@ -215,7 +191,7 @@ describe("GET /system/update", () => {
|
|
|
215
191
|
it("omits both schema-version fields when the state file predates them", async () => {
|
|
216
192
|
writeState(okState);
|
|
217
193
|
|
|
218
|
-
const result = (await getUpdate(buildEvent(
|
|
194
|
+
const result = (await getUpdate(buildEvent(USER))) as {
|
|
219
195
|
currentSchemaVersion?: number;
|
|
220
196
|
check: { schemaVersion?: number };
|
|
221
197
|
};
|
|
@@ -232,7 +208,7 @@ describe("GET /system/update", () => {
|
|
|
232
208
|
run: null,
|
|
233
209
|
});
|
|
234
210
|
|
|
235
|
-
const result = (await getUpdate(buildEvent(
|
|
211
|
+
const result = (await getUpdate(buildEvent(USER))) as {
|
|
236
212
|
currentSchemaVersion?: number;
|
|
237
213
|
check: { schemaVersion?: number };
|
|
238
214
|
};
|
|
@@ -248,7 +224,7 @@ describe("GET /system/update", () => {
|
|
|
248
224
|
run: null,
|
|
249
225
|
});
|
|
250
226
|
|
|
251
|
-
const result = (await getUpdate(buildEvent(
|
|
227
|
+
const result = (await getUpdate(buildEvent(USER))) as {
|
|
252
228
|
currentSchemaVersion?: number;
|
|
253
229
|
check: { schemaVersion?: number };
|
|
254
230
|
};
|
|
@@ -262,17 +238,34 @@ describe("POST /system/update", () => {
|
|
|
262
238
|
it("returns 404 when no manifest URL is configured", async () => {
|
|
263
239
|
delete process.env.REMIT_UPDATE_MANIFEST_URL;
|
|
264
240
|
|
|
265
|
-
const result = await applyUpdate("v1.5.0", buildEvent(
|
|
241
|
+
const result = await applyUpdate("v1.5.0", buildEvent(USER));
|
|
266
242
|
|
|
267
243
|
assert.ok(hasStatus(result, 404));
|
|
268
244
|
});
|
|
269
245
|
|
|
270
|
-
it("returns
|
|
246
|
+
it("returns 401 without writing request.json when the caller is not authenticated", async () => {
|
|
247
|
+
writeState(okState);
|
|
248
|
+
|
|
249
|
+
const result = await applyUpdate("v1.5.0", buildEvent());
|
|
250
|
+
|
|
251
|
+
assert.ok(hasStatus(result, 401));
|
|
252
|
+
assert.throws(() => readFileSync(join(controlDir, "request.json"), "utf8"));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns 202 for any authenticated caller and records that identity", async () => {
|
|
271
256
|
writeState(okState);
|
|
272
257
|
|
|
273
|
-
const result = await applyUpdate(
|
|
258
|
+
const result = (await applyUpdate(
|
|
259
|
+
"v1.5.0",
|
|
260
|
+
buildEvent("another-signed-in-user"),
|
|
261
|
+
)) as { statusCode: number };
|
|
262
|
+
|
|
263
|
+
assert.equal(result.statusCode, 202);
|
|
274
264
|
|
|
275
|
-
|
|
265
|
+
const request = JSON.parse(
|
|
266
|
+
readFileSync(join(controlDir, "request.json"), "utf8"),
|
|
267
|
+
);
|
|
268
|
+
assert.equal(request.requestedBy, "another-signed-in-user");
|
|
276
269
|
});
|
|
277
270
|
|
|
278
271
|
it("returns 409 when a run is already in flight", async () => {
|
|
@@ -291,7 +284,7 @@ describe("POST /system/update", () => {
|
|
|
291
284
|
},
|
|
292
285
|
});
|
|
293
286
|
|
|
294
|
-
await assert.rejects(applyUpdate("v1.5.0", buildEvent(
|
|
287
|
+
await assert.rejects(applyUpdate("v1.5.0", buildEvent(USER)), {
|
|
295
288
|
statusCode: 409,
|
|
296
289
|
});
|
|
297
290
|
});
|
|
@@ -299,13 +292,13 @@ describe("POST /system/update", () => {
|
|
|
299
292
|
it("returns 422 when the target is not the checked version", async () => {
|
|
300
293
|
writeState(okState);
|
|
301
294
|
|
|
302
|
-
await assert.rejects(applyUpdate("v9.9.9", buildEvent(
|
|
295
|
+
await assert.rejects(applyUpdate("v9.9.9", buildEvent(USER)), {
|
|
303
296
|
statusCode: 422,
|
|
304
297
|
});
|
|
305
298
|
});
|
|
306
299
|
|
|
307
300
|
it("returns 422 when no check has ever succeeded", async () => {
|
|
308
|
-
await assert.rejects(applyUpdate("v1.5.0", buildEvent(
|
|
301
|
+
await assert.rejects(applyUpdate("v1.5.0", buildEvent(USER)), {
|
|
309
302
|
statusCode: 422,
|
|
310
303
|
});
|
|
311
304
|
});
|
|
@@ -313,7 +306,7 @@ describe("POST /system/update", () => {
|
|
|
313
306
|
it("returns 202 with a run block carrying a fresh runId", async () => {
|
|
314
307
|
writeState(okState);
|
|
315
308
|
|
|
316
|
-
const result = (await applyUpdate("v1.5.0", buildEvent(
|
|
309
|
+
const result = (await applyUpdate("v1.5.0", buildEvent(USER))) as {
|
|
317
310
|
statusCode: number;
|
|
318
311
|
body: { run: { runId: string; targetVersion: string; outcome: null } };
|
|
319
312
|
};
|
|
@@ -327,7 +320,7 @@ describe("POST /system/update", () => {
|
|
|
327
320
|
it("writes request.json with exactly the four seam fields and nothing else", async () => {
|
|
328
321
|
writeState(okState);
|
|
329
322
|
|
|
330
|
-
await applyUpdate("v1.5.0", buildEvent(
|
|
323
|
+
await applyUpdate("v1.5.0", buildEvent(USER));
|
|
331
324
|
|
|
332
325
|
const request = JSON.parse(
|
|
333
326
|
readFileSync(join(controlDir, "request.json"), "utf8"),
|
|
@@ -340,7 +333,7 @@ describe("POST /system/update", () => {
|
|
|
340
333
|
"targetVersion",
|
|
341
334
|
]);
|
|
342
335
|
assert.equal(request.targetVersion, "v1.5.0");
|
|
343
|
-
assert.equal(request.requestedBy,
|
|
336
|
+
assert.equal(request.requestedBy, USER);
|
|
344
337
|
for (const forbidden of ["registry", "image", "digest"]) {
|
|
345
338
|
assert.ok(!(forbidden in request));
|
|
346
339
|
}
|
|
@@ -10,7 +10,6 @@ import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
|
10
10
|
import type { Context } from "openapi-backend";
|
|
11
11
|
import { getSubFromEvent } from "../auth.js";
|
|
12
12
|
import type { OperationHandler, SystemOperationIds } from "../types.js";
|
|
13
|
-
import { requireInstanceOwner } from "./owner-guard.js";
|
|
14
13
|
|
|
15
14
|
class TargetVersionMismatchError extends HTTPError {
|
|
16
15
|
name = "TargetVersionMismatchError";
|
|
@@ -33,11 +32,17 @@ const notFound = (): APIGatewayProxyResult => ({
|
|
|
33
32
|
body: JSON.stringify({ message: "Not found" }),
|
|
34
33
|
});
|
|
35
34
|
|
|
35
|
+
const unauthorized = (): APIGatewayProxyResult => ({
|
|
36
|
+
statusCode: 401,
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ message: "Unauthorized" }),
|
|
39
|
+
});
|
|
40
|
+
|
|
36
41
|
/**
|
|
37
42
|
* The self-update seam is off unless a manifest URL is configured (RFC 037 D8),
|
|
38
43
|
* which the hosted deployment leaves unset. Both endpoints answer 404 in that
|
|
39
|
-
* case
|
|
40
|
-
*
|
|
44
|
+
* case. Where it is on, any authenticated caller may use it — the account list
|
|
45
|
+
* is the trust boundary of a self-hosted instance.
|
|
41
46
|
*/
|
|
42
47
|
const guardManifestConfigured = (): APIGatewayProxyResult | null =>
|
|
43
48
|
manifestUrl() ? null : notFound();
|
|
@@ -140,8 +145,7 @@ export const SystemOperations: Record<
|
|
|
140
145
|
if (offSurface) return offSurface;
|
|
141
146
|
|
|
142
147
|
const event = args[0] as APIGatewayProxyEvent;
|
|
143
|
-
|
|
144
|
-
if (forbidden) return forbidden;
|
|
148
|
+
if (!getSubFromEvent(event)) return unauthorized();
|
|
145
149
|
|
|
146
150
|
return readState() ?? emptyResource();
|
|
147
151
|
},
|
|
@@ -154,8 +158,8 @@ export const SystemOperations: Record<
|
|
|
154
158
|
if (offSurface) return offSurface;
|
|
155
159
|
|
|
156
160
|
const event = args[0] as APIGatewayProxyEvent;
|
|
157
|
-
const
|
|
158
|
-
if (
|
|
161
|
+
const sub = getSubFromEvent(event);
|
|
162
|
+
if (!sub) return unauthorized();
|
|
159
163
|
|
|
160
164
|
const { targetVersion } = context.request.requestBody as {
|
|
161
165
|
targetVersion: string;
|
|
@@ -178,7 +182,7 @@ export const SystemOperations: Record<
|
|
|
178
182
|
runId,
|
|
179
183
|
targetVersion,
|
|
180
184
|
requestedAt,
|
|
181
|
-
requestedBy:
|
|
185
|
+
requestedBy: sub,
|
|
182
186
|
});
|
|
183
187
|
|
|
184
188
|
return {
|
|
@@ -213,7 +213,14 @@ const candidate = (
|
|
|
213
213
|
over: Partial<OrganizeCandidate["message"]> = {},
|
|
214
214
|
): OrganizeCandidate => ({
|
|
215
215
|
messageId,
|
|
216
|
-
message: {
|
|
216
|
+
message: {
|
|
217
|
+
from: "",
|
|
218
|
+
fromName: "",
|
|
219
|
+
subject: "",
|
|
220
|
+
text: "",
|
|
221
|
+
listId: "",
|
|
222
|
+
...over,
|
|
223
|
+
},
|
|
217
224
|
});
|
|
218
225
|
|
|
219
226
|
describe("matchOrganize", () => {
|
|
@@ -597,3 +604,115 @@ describe("back-apply pipeline (matchOrganize -> applyOrganize)", () => {
|
|
|
597
604
|
);
|
|
598
605
|
});
|
|
599
606
|
});
|
|
607
|
+
|
|
608
|
+
describe("matchOrganize with ListId and FromDomain clauses", () => {
|
|
609
|
+
const senderChunk = (
|
|
610
|
+
messageId: string,
|
|
611
|
+
fromEmail: string,
|
|
612
|
+
over: Partial<ChunkMetadata> = {},
|
|
613
|
+
): VectorRecord => ({
|
|
614
|
+
chunkId: `${messageId}#sender`,
|
|
615
|
+
vector: ANCHOR_VECTOR,
|
|
616
|
+
metadata: metadata({
|
|
617
|
+
messageId,
|
|
618
|
+
chunkType: "sender",
|
|
619
|
+
textPreview: fromEmail,
|
|
620
|
+
...over,
|
|
621
|
+
}),
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("matches a ListId clause exactly off the vector-free projection", async () => {
|
|
625
|
+
const deps = vectorlessDeps([
|
|
626
|
+
candidate("msg-1", { listId: "weekly.news.example.com" }),
|
|
627
|
+
candidate("msg-2", { listId: "news.example.com" }),
|
|
628
|
+
candidate("msg-3", {}),
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
632
|
+
...predicate(),
|
|
633
|
+
anchorMessageId: "None",
|
|
634
|
+
literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
assert.deepEqual(messageIds, ["msg-1"]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("matches a FromDomain clause public-suffix aware off the vector-free projection", async () => {
|
|
641
|
+
const deps = vectorlessDeps([
|
|
642
|
+
candidate("msg-1", { from: "notifications@github.com" }),
|
|
643
|
+
candidate("msg-2", { from: "ci@sub.github.com" }),
|
|
644
|
+
candidate("msg-3", { from: "attacker@github.com.evil.example" }),
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const { messageIds } = await matchOrganize(deps, ACCOUNT_CONFIG_ID, {
|
|
648
|
+
...predicate(),
|
|
649
|
+
anchorMessageId: "None",
|
|
650
|
+
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
assert.deepEqual(messageIds, ["msg-1", "msg-2"]);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("round-trips ListId and FromDomain through the semantic-arm chunk projection", async () => {
|
|
657
|
+
const store = createMemoryVectorStore();
|
|
658
|
+
await store.upsert([
|
|
659
|
+
senderChunk("msg-1", "ci@github.com", {
|
|
660
|
+
listId: "actions.github.com",
|
|
661
|
+
}),
|
|
662
|
+
bodyChunk("msg-1", ANCHOR_VECTOR, { listId: "actions.github.com" }),
|
|
663
|
+
senderChunk("msg-2", "hi@othersender.example", {
|
|
664
|
+
listId: "other.list.example",
|
|
665
|
+
}),
|
|
666
|
+
bodyChunk("msg-2", ANCHOR_VECTOR, { listId: "other.list.example" }),
|
|
667
|
+
]);
|
|
668
|
+
|
|
669
|
+
const byListId = await matchOrganize(matchDeps(store), ACCOUNT_CONFIG_ID, {
|
|
670
|
+
...predicate(),
|
|
671
|
+
literalClauses: [{ field: "ListId", value: "actions.github.com" }],
|
|
672
|
+
});
|
|
673
|
+
assert.deepEqual(byListId.messageIds, ["msg-1"]);
|
|
674
|
+
|
|
675
|
+
const byFromDomain = await matchOrganize(
|
|
676
|
+
matchDeps(store),
|
|
677
|
+
ACCOUNT_CONFIG_ID,
|
|
678
|
+
{
|
|
679
|
+
...predicate(),
|
|
680
|
+
literalClauses: [{ field: "FromDomain", value: "github.com" }],
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
assert.deepEqual(byFromDomain.messageIds, ["msg-1"]);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("applies a ListId predicate to exactly the previewed set (preview == apply)", async () => {
|
|
687
|
+
const deps = vectorlessDeps([
|
|
688
|
+
candidate("msg-1", { listId: "weekly.news.example.com" }),
|
|
689
|
+
candidate("msg-2", { listId: "weekly.news.example.com" }),
|
|
690
|
+
candidate("msg-3", { listId: "other.example.com" }),
|
|
691
|
+
]);
|
|
692
|
+
const p = predicate({
|
|
693
|
+
anchorMessageId: "None",
|
|
694
|
+
actionLabelId: "lbl-list",
|
|
695
|
+
literalClauses: [{ field: "ListId", value: "weekly.news.example.com" }],
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const previewed = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
|
|
699
|
+
const applied = await matchOrganize(deps, ACCOUNT_CONFIG_ID, p);
|
|
700
|
+
assert.deepEqual(previewed, applied);
|
|
701
|
+
assert.deepEqual(previewed.messageIds, ["msg-1", "msg-2"]);
|
|
702
|
+
|
|
703
|
+
const tracked = trackingClient();
|
|
704
|
+
const result = await applyOrganize(
|
|
705
|
+
{ client: tracked.client },
|
|
706
|
+
ACCOUNT_CONFIG_ID,
|
|
707
|
+
applied.messageIds,
|
|
708
|
+
p,
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
assert.equal(result.applied, 2);
|
|
712
|
+
assert.equal(result.failed, 0);
|
|
713
|
+
assert.deepEqual(tracked.labeled.map((row) => row.messageId).sort(), [
|
|
714
|
+
"msg-1",
|
|
715
|
+
"msg-2",
|
|
716
|
+
]);
|
|
717
|
+
});
|
|
718
|
+
});
|
package/src/service/organize.ts
CHANGED
|
@@ -131,11 +131,13 @@ const filterMessageFromChunks = (
|
|
|
131
131
|
let subject = "";
|
|
132
132
|
let fromName = "";
|
|
133
133
|
let from = "";
|
|
134
|
+
let listId = "";
|
|
134
135
|
const textParts: string[] = [];
|
|
135
136
|
for (const record of records) {
|
|
136
137
|
const meta = record.metadata;
|
|
137
138
|
if (meta.subject && !subject) subject = meta.subject;
|
|
138
139
|
if (meta.fromName && !fromName) fromName = meta.fromName;
|
|
140
|
+
if (meta.listId && !listId) listId = meta.listId;
|
|
139
141
|
if (meta.chunkType === "sender" && meta.textPreview && !from) {
|
|
140
142
|
from = meta.textPreview;
|
|
141
143
|
}
|
|
@@ -146,7 +148,7 @@ const filterMessageFromChunks = (
|
|
|
146
148
|
textParts.push(meta.textPreview);
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
return { from, fromName, subject, text: textParts.join("\n") };
|
|
151
|
+
return { from, fromName, subject, listId, text: textParts.join("\n") };
|
|
150
152
|
};
|
|
151
153
|
|
|
152
154
|
/**
|
|
@@ -355,8 +357,10 @@ const buildSemanticFromEnv = (): OrganizeSemanticDeps => {
|
|
|
355
357
|
* deployment that ships no vector pipeline. Deduped by message id — the same
|
|
356
358
|
* mail filed in two folders is one candidate.
|
|
357
359
|
*
|
|
358
|
-
* `From`/`Subject` come from the row verbatim (full fidelity)
|
|
359
|
-
*
|
|
360
|
+
* `From`/`Subject`/`listId` come from the row verbatim (full fidelity), so a
|
|
361
|
+
* `From`, `Subject`, `FromDomain` or `ListId` clause matches here exactly as it
|
|
362
|
+
* does at index time. `text` (the body) is left empty: the thread row carries
|
|
363
|
+
* only a truncated `snippet`, and matching
|
|
360
364
|
* a body-content clause against a preview would silently diverge from the live
|
|
361
365
|
* index-time filter's full-body match. Body-content (`HasWords`) clauses are
|
|
362
366
|
* rejected before this is read (see {@link assertNoBodyContentClause}), so the
|
|
@@ -384,6 +388,7 @@ const listAccountFilterMessagesFromClient =
|
|
|
384
388
|
fromName: row.fromName ?? "",
|
|
385
389
|
subject: row.subject ?? "",
|
|
386
390
|
text: "",
|
|
391
|
+
listId: row.listId ?? "",
|
|
387
392
|
},
|
|
388
393
|
});
|
|
389
394
|
if (candidates.length >= limit) return candidates;
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { after, afterEach, before, describe, it } from "node:test";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { createInstanceOwnerStore } from "@remit/auth-service";
|
|
7
|
-
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
8
|
-
import Database from "better-sqlite3";
|
|
9
|
-
import { requireInstanceOwner } from "./owner-guard.js";
|
|
10
|
-
|
|
11
|
-
const tmpRoot = join(
|
|
12
|
-
fileURLToPath(new URL(".", import.meta.url)),
|
|
13
|
-
"..",
|
|
14
|
-
"..",
|
|
15
|
-
".tmp",
|
|
16
|
-
"owner-guard",
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
const readMigrationSql = (relativePath: string): string =>
|
|
20
|
-
readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
|
|
21
|
-
|
|
22
|
-
const authMigrationSql = readMigrationSql(
|
|
23
|
-
"../../../../deploy/vps/migrations-sqlite/auth/0000_rainy_iron_monger.sql",
|
|
24
|
-
);
|
|
25
|
-
const metaMigrationSql = readMigrationSql(
|
|
26
|
-
"../../../../deploy/vps/migrations-sqlite/meta/0000_demonic_lilith.sql",
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
let dbPath: string;
|
|
30
|
-
|
|
31
|
-
before(() => {
|
|
32
|
-
mkdirSync(tmpRoot, { recursive: true });
|
|
33
|
-
dbPath = join(mkdtempSync(join(tmpRoot, "db-")), "app.db");
|
|
34
|
-
const sqlite = new Database(dbPath);
|
|
35
|
-
for (const sql of [authMigrationSql, metaMigrationSql]) {
|
|
36
|
-
for (const statement of sql.split("--> statement-breakpoint")) {
|
|
37
|
-
sqlite.exec(statement);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
sqlite
|
|
41
|
-
.prepare(
|
|
42
|
-
"INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
|
|
43
|
-
)
|
|
44
|
-
.run("the-owner", "the-owner", "the-owner@example.com", 1000, 1000);
|
|
45
|
-
sqlite.close();
|
|
46
|
-
process.env.DATA_BACKEND = "sqlite";
|
|
47
|
-
process.env.SQLITE_DB_PATH = dbPath;
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
after(() => {
|
|
51
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
afterEach(() => {
|
|
55
|
-
delete process.env.REMIT_OWNER_EMAIL;
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const buildEvent = (sub?: string): APIGatewayProxyEvent =>
|
|
59
|
-
({
|
|
60
|
-
headers: {},
|
|
61
|
-
requestContext: sub ? { authorizer: { claims: { sub } } } : {},
|
|
62
|
-
}) as unknown as APIGatewayProxyEvent;
|
|
63
|
-
|
|
64
|
-
describe("requireInstanceOwner", () => {
|
|
65
|
-
it("rejects a request with no authenticated caller", async () => {
|
|
66
|
-
const result = await requireInstanceOwner(buildEvent());
|
|
67
|
-
|
|
68
|
-
assert.equal(result?.statusCode, 403);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("rejects a caller who has not claimed ownership", async () => {
|
|
72
|
-
const result = await requireInstanceOwner(buildEvent("not-the-owner"));
|
|
73
|
-
|
|
74
|
-
assert.equal(result?.statusCode, 403);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it("lets the instance owner through", async () => {
|
|
78
|
-
const store = await createInstanceOwnerStore({
|
|
79
|
-
provider: "sqlite",
|
|
80
|
-
connectionString: dbPath,
|
|
81
|
-
});
|
|
82
|
-
await store.claimIfUnclaimed("the-owner");
|
|
83
|
-
await store.close();
|
|
84
|
-
|
|
85
|
-
const result = await requireInstanceOwner(buildEvent("the-owner"));
|
|
86
|
-
|
|
87
|
-
assert.equal(result, null);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { isInstanceOwner } from "@remit/auth-service";
|
|
2
|
-
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
3
|
-
import { getSubFromEvent } from "../auth.js";
|
|
4
|
-
|
|
5
|
-
const forbidden = (message: string): APIGatewayProxyResult => ({
|
|
6
|
-
statusCode: 403,
|
|
7
|
-
headers: { "Content-Type": "application/json" },
|
|
8
|
-
body: JSON.stringify({ message }),
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Gate a request to the standalone instance owner (RFC 037 D8) — the first
|
|
13
|
-
* account to register, or whoever `REMIT_OWNER_EMAIL` names. Returns `null` to
|
|
14
|
-
* let the request proceed, or a 403 `APIGatewayProxyResult` otherwise. Callers
|
|
15
|
-
* wire this in ahead of the handler; it does not run the handler itself.
|
|
16
|
-
*/
|
|
17
|
-
export const requireInstanceOwner = async (
|
|
18
|
-
event: APIGatewayProxyEvent,
|
|
19
|
-
): Promise<APIGatewayProxyResult | null> => {
|
|
20
|
-
const sub = getSubFromEvent(event);
|
|
21
|
-
if (!sub) return forbidden("Instance owner required");
|
|
22
|
-
if (!(await isInstanceOwner(sub)))
|
|
23
|
-
return forbidden("Instance owner required");
|
|
24
|
-
return null;
|
|
25
|
-
};
|