@remit/backend 0.0.51 → 0.0.53
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/dev-server/content-auth.test.ts +9 -9
- package/dev-server/content-auth.ts +2 -2
- package/dev-server/relational-health.test.ts +0 -8
- package/dev-server/relational-health.ts +3 -21
- package/dev-server/server.ts +7 -9
- package/package.json +4 -7
- package/scripts/backfill-list-id.ts +18 -4
- package/src/data-backend.test.ts +1 -3
- package/src/data-backend.ts +8 -14
- package/src/derive/contentSignature.test.ts +1 -19
- package/src/derive/contentSignature.ts +10 -10
- package/src/derive/contentUrl.ts +1 -1
- package/src/handlers/account-oauth.ts +1 -1
- package/src/handlers/account.ts +1 -1
- package/src/handlers/address.ts +1 -1
- package/src/handlers/config.ts +1 -1
- package/src/handlers/filter.ts +1 -1
- package/src/handlers/folder-role.ts +1 -1
- package/src/handlers/label.ts +1 -1
- package/src/handlers/mailbox.ts +3 -3
- package/src/handlers/me.test.ts +1 -1
- package/src/handlers/me.ts +1 -1
- package/src/handlers/message.ts +1 -1
- package/src/handlers/organize.ts +1 -1
- package/src/handlers/outbox.ts +1 -1
- package/src/handlers/search.ts +1 -1
- package/src/handlers/sync.ts +1 -1
- package/src/handlers/thread.ts +1 -1
- package/src/handlers/unified-threads.ts +1 -1
- package/src/index.ts +2 -2
- package/src/jwt-auth.test.ts +6 -6
- package/src/jwt-auth.ts +2 -2
- package/src/service/compose-sqlite.ts +8 -14
- package/src/service/create-remit-client.ts +6 -6
- package/src/service/data-client.test.ts +96 -0
- package/src/service/data-client.ts +58 -0
- package/src/service/organize.test.ts +1 -1
- package/src/service/organize.ts +1 -1
- package/src/service/semantic-capability.test.ts +10 -16
- package/src/service/semantic-capability.ts +1 -1
- package/src/service/compose-postgres.ts +0 -76
- package/src/service/dynamodb.test.ts +0 -98
- package/src/service/dynamodb.ts +0 -57
|
@@ -8,7 +8,7 @@ const PATH = "accounts/cfg-alice/acc-alice/messages/msg-1/parts/1.2";
|
|
|
8
8
|
const now = () => Math.floor(Date.now() / 1000);
|
|
9
9
|
|
|
10
10
|
describe("authorizeContentRequest", () => {
|
|
11
|
-
it("is a no-op
|
|
11
|
+
it("is a no-op on the AWS path (unsigned URLs)", () => {
|
|
12
12
|
const result = authorizeContentRequest({
|
|
13
13
|
dataBackend: undefined,
|
|
14
14
|
secret: undefined,
|
|
@@ -20,10 +20,10 @@ describe("authorizeContentRequest", () => {
|
|
|
20
20
|
assert.deepEqual(result, { authorized: true });
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
it("authorizes a validly signed request
|
|
23
|
+
it("authorizes a validly signed request on the self-host stack", () => {
|
|
24
24
|
const { exp, sig } = createContentSigner(SECRET)(PATH);
|
|
25
25
|
const result = authorizeContentRequest({
|
|
26
|
-
dataBackend: "
|
|
26
|
+
dataBackend: "sqlite",
|
|
27
27
|
secret: SECRET,
|
|
28
28
|
relativePath: PATH,
|
|
29
29
|
exp: String(exp),
|
|
@@ -35,7 +35,7 @@ describe("authorizeContentRequest", () => {
|
|
|
35
35
|
|
|
36
36
|
it("returns 401 when the signature is absent", () => {
|
|
37
37
|
const result = authorizeContentRequest({
|
|
38
|
-
dataBackend: "
|
|
38
|
+
dataBackend: "sqlite",
|
|
39
39
|
secret: SECRET,
|
|
40
40
|
relativePath: PATH,
|
|
41
41
|
exp: undefined,
|
|
@@ -49,7 +49,7 @@ describe("authorizeContentRequest", () => {
|
|
|
49
49
|
it("returns 403 for a signature minted for another account's path", () => {
|
|
50
50
|
const { exp, sig } = createContentSigner(SECRET)(PATH);
|
|
51
51
|
const result = authorizeContentRequest({
|
|
52
|
-
dataBackend: "
|
|
52
|
+
dataBackend: "sqlite",
|
|
53
53
|
secret: SECRET,
|
|
54
54
|
relativePath: "accounts/cfg-bob/acc-bob/messages/msg-9/parts/1.2",
|
|
55
55
|
exp: String(exp),
|
|
@@ -63,7 +63,7 @@ describe("authorizeContentRequest", () => {
|
|
|
63
63
|
it("returns 403 for an expired signature", () => {
|
|
64
64
|
const { exp, sig } = createContentSigner(SECRET, -20)(PATH);
|
|
65
65
|
const result = authorizeContentRequest({
|
|
66
|
-
dataBackend: "
|
|
66
|
+
dataBackend: "sqlite",
|
|
67
67
|
secret: SECRET,
|
|
68
68
|
relativePath: PATH,
|
|
69
69
|
exp: String(exp),
|
|
@@ -74,9 +74,9 @@ describe("authorizeContentRequest", () => {
|
|
|
74
74
|
assert.equal(result.authorized === false && result.status, 403);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
it("fails closed with 500
|
|
77
|
+
it("fails closed with 500 when no signing secret is configured", () => {
|
|
78
78
|
const result = authorizeContentRequest({
|
|
79
|
-
dataBackend: "
|
|
79
|
+
dataBackend: "sqlite",
|
|
80
80
|
secret: undefined,
|
|
81
81
|
relativePath: PATH,
|
|
82
82
|
exp: "123",
|
|
@@ -87,7 +87,7 @@ describe("authorizeContentRequest", () => {
|
|
|
87
87
|
assert.equal(result.authorized === false && result.status, 500);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it("enforces signatures
|
|
90
|
+
it("enforces signatures on every self-host request", () => {
|
|
91
91
|
const { exp, sig } = createContentSigner(SECRET)(PATH);
|
|
92
92
|
const valid = authorizeContentRequest({
|
|
93
93
|
dataBackend: "sqlite",
|
|
@@ -2,7 +2,7 @@ import { verifyContentSignature } from "../src/derive/contentSignature.js";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Decide whether a `/content` request is authorized. Enforcement applies on the
|
|
5
|
-
* self-host SQL
|
|
5
|
+
* self-host SQL backend (sqlite), where this server is the
|
|
6
6
|
* deployed backend container and content URLs are signed. On AWS-local dev
|
|
7
7
|
* (`DATA_BACKEND` unset) `/content` is served straight from the filesystem
|
|
8
8
|
* stand-in for CloudFront and URLs are unsigned, so the check is a no-op.
|
|
@@ -25,7 +25,7 @@ export const authorizeContentRequest = (
|
|
|
25
25
|
):
|
|
26
26
|
| { authorized: true }
|
|
27
27
|
| { authorized: false; status: number; reason: string } => {
|
|
28
|
-
if (input.dataBackend !== "
|
|
28
|
+
if (input.dataBackend !== "sqlite") {
|
|
29
29
|
return { authorized: true };
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -80,12 +80,4 @@ describe("checkRelationalStore", () => {
|
|
|
80
80
|
chmodSync(dbPath, 0o400);
|
|
81
81
|
assert.equal(await checkRelationalStore(), false);
|
|
82
82
|
});
|
|
83
|
-
|
|
84
|
-
it("is false when Postgres connection fails", async () => {
|
|
85
|
-
process.env.DATA_BACKEND = "postgres";
|
|
86
|
-
process.env.PG_CONNECTION_URL =
|
|
87
|
-
"postgresql://remit:remit@127.0.0.1:1/nonexistent";
|
|
88
|
-
|
|
89
|
-
assert.equal(await checkRelationalStore(), false);
|
|
90
|
-
});
|
|
91
83
|
});
|
|
@@ -21,23 +21,9 @@ const pingSqlite = async (): Promise<void> => {
|
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const pingPostgres = async (): Promise<void> => {
|
|
25
|
-
const { Client } = await import("pg");
|
|
26
|
-
const client = new Client({
|
|
27
|
-
connectionString: process.env.PG_CONNECTION_URL,
|
|
28
|
-
connectionTimeoutMillis: 3000,
|
|
29
|
-
});
|
|
30
|
-
await client.connect();
|
|
31
|
-
try {
|
|
32
|
-
await client.query("SELECT 1");
|
|
33
|
-
} finally {
|
|
34
|
-
await client.end();
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
24
|
/**
|
|
39
|
-
* A trivial read against
|
|
40
|
-
*
|
|
25
|
+
* A trivial read against the relational store — the dependency `/health` must
|
|
26
|
+
* actually exercise (RFC 037 D5, R9) so a backend that
|
|
41
27
|
* booted with an unusable database reports unhealthy instead of a bare 200. The
|
|
42
28
|
* AWS/DynamoDB path has no relational store and is never checked.
|
|
43
29
|
*/
|
|
@@ -45,11 +31,7 @@ export const checkRelationalStore = async (): Promise<boolean> => {
|
|
|
45
31
|
if (!isSelfHostSqlBackend()) return true;
|
|
46
32
|
|
|
47
33
|
try {
|
|
48
|
-
|
|
49
|
-
await pingSqlite();
|
|
50
|
-
} else {
|
|
51
|
-
await pingPostgres();
|
|
52
|
-
}
|
|
34
|
+
await pingSqlite();
|
|
53
35
|
return true;
|
|
54
36
|
} catch {
|
|
55
37
|
return false;
|
package/dev-server/server.ts
CHANGED
|
@@ -17,7 +17,7 @@ import express, {
|
|
|
17
17
|
} from "express";
|
|
18
18
|
import { handler, OpenAPISpec } from "../src/index.js";
|
|
19
19
|
import { safeJsonParse } from "../src/json.js";
|
|
20
|
-
import { getClient } from "../src/service/
|
|
20
|
+
import { getClient } from "../src/service/data-client.js";
|
|
21
21
|
import { authorizeContentRequest } from "./content-auth.js";
|
|
22
22
|
import { serveContent } from "./content-handler.js";
|
|
23
23
|
import { resolveContentPath } from "./content-path.js";
|
|
@@ -28,15 +28,13 @@ import { collectAccountSyncAges } from "./sync-age.js";
|
|
|
28
28
|
|
|
29
29
|
const app = express();
|
|
30
30
|
|
|
31
|
-
// The self-host relational
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
const isSelfHostBackend =
|
|
35
|
-
process.env.DATA_BACKEND === "postgres" ||
|
|
36
|
-
process.env.DATA_BACKEND === "sqlite";
|
|
31
|
+
// The self-host relational backend runs better-auth and the APISIX edge; the
|
|
32
|
+
// AWS-local (DynamoDB) dev path runs neither. Everything gated on "not the
|
|
33
|
+
// AWS-local path" keys off this.
|
|
34
|
+
const isSelfHostBackend = process.env.DATA_BACKEND === "sqlite";
|
|
37
35
|
|
|
38
36
|
// CORS is driven by CORS_ALLOWED_ORIGINS (comma-separated, or `*`). On the
|
|
39
|
-
// self-host
|
|
37
|
+
// self-host backend it is required config — refuse to start if unset, so the
|
|
40
38
|
// deployed edge is never accidentally wide open by omission. On the AWS-local
|
|
41
39
|
// dev path it defaults to `*` to keep the existing local flow working.
|
|
42
40
|
const configuredCorsOrigins = parseAllowedOrigins(
|
|
@@ -218,7 +216,7 @@ const STORAGE_BASE = resolve(
|
|
|
218
216
|
app.get(/^\/content\/.+$/, async (req: Request, res: Response) => {
|
|
219
217
|
const storageKey = req.path.replace(/^\/content\//, "");
|
|
220
218
|
|
|
221
|
-
// On the
|
|
219
|
+
// On the self-host stack this route is the deployed content-delivery surface;
|
|
222
220
|
// require a valid signed URL (HMAC + expiry, scoped to the owning account)
|
|
223
221
|
// before touching storage. Bearer auth can't ride on an `<img src>` content
|
|
224
222
|
// load, so the signature carried in the query string is the authorization.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/backend",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.53",
|
|
4
4
|
"description": "Remit Mail Inspector API backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"default": "./src/index.ts"
|
|
13
13
|
},
|
|
14
14
|
"./client": {
|
|
15
|
-
"types": "./src/service/
|
|
16
|
-
"default": "./src/service/
|
|
15
|
+
"types": "./src/service/data-client.ts",
|
|
16
|
+
"default": "./src/service/data-client.ts"
|
|
17
17
|
},
|
|
18
18
|
"./create-remit-client": {
|
|
19
19
|
"types": "./src/service/create-remit-client.ts",
|
|
@@ -73,14 +73,11 @@
|
|
|
73
73
|
"@aws-sdk/client-secrets-manager": "*",
|
|
74
74
|
"@aws-sdk/client-sqs": "*",
|
|
75
75
|
"better-sqlite3": "^12.11.1",
|
|
76
|
-
"drizzle-orm": "^0.45.2",
|
|
77
76
|
"expect-env": "*",
|
|
78
77
|
"openapi-backend": "*",
|
|
79
78
|
"p-map": "*",
|
|
80
|
-
"pg": "^8.0.0",
|
|
81
79
|
"@types/aws-lambda": "*",
|
|
82
|
-
"@types/better-sqlite3": "^7.6.13"
|
|
83
|
-
"@types/pg": "^8.0.0"
|
|
80
|
+
"@types/better-sqlite3": "^7.6.13"
|
|
84
81
|
},
|
|
85
82
|
"publishConfig": {
|
|
86
83
|
"access": "public"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { logger } from "@remit/logger-lambda";
|
|
5
5
|
import {
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ListIdBackfillCheckpoint,
|
|
8
8
|
type ListIdBackfillCheckpointStore,
|
|
9
9
|
} from "@remit/mailbox-service";
|
|
10
|
-
import { getClient } from "../src/service/
|
|
10
|
+
import { getClient } from "../src/service/data-client.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* One-time, full-corpus `ThreadMessage.listId` backfill (issue #263). Ships as
|
|
@@ -29,15 +29,29 @@ const CHECKPOINT_PATH =
|
|
|
29
29
|
process.env.LIST_ID_BACKFILL_CHECKPOINT_PATH ??
|
|
30
30
|
"/data/sqlite/list-id-backfill-checkpoint.json";
|
|
31
31
|
|
|
32
|
+
const isCheckpoint = (value: unknown): value is ListIdBackfillCheckpoint => {
|
|
33
|
+
if (typeof value !== "object" || value === null) return false;
|
|
34
|
+
if (!("accountConfigId" in value)) return false;
|
|
35
|
+
const candidate = value as Record<string, unknown>;
|
|
36
|
+
return (
|
|
37
|
+
typeof candidate.accountConfigId === "string" &&
|
|
38
|
+
(candidate.continuationToken === undefined ||
|
|
39
|
+
typeof candidate.continuationToken === "string")
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
32
43
|
const fileCheckpointStore: ListIdBackfillCheckpointStore = {
|
|
33
44
|
load: async () => {
|
|
34
45
|
if (!existsSync(CHECKPOINT_PATH)) return undefined;
|
|
35
46
|
const raw = await readFile(CHECKPOINT_PATH, "utf8");
|
|
36
|
-
|
|
47
|
+
const parsed: unknown = JSON.parse(raw);
|
|
48
|
+
return isCheckpoint(parsed) ? parsed : undefined;
|
|
37
49
|
},
|
|
38
50
|
save: async (checkpoint) => {
|
|
39
51
|
await mkdir(dirname(CHECKPOINT_PATH), { recursive: true });
|
|
40
|
-
|
|
52
|
+
const staging = `${CHECKPOINT_PATH}.writing`;
|
|
53
|
+
await writeFile(staging, JSON.stringify(checkpoint));
|
|
54
|
+
await rename(staging, CHECKPOINT_PATH);
|
|
41
55
|
},
|
|
42
56
|
clear: async () => {
|
|
43
57
|
await rm(CHECKPOINT_PATH, { force: true });
|
package/src/data-backend.test.ts
CHANGED
|
@@ -9,9 +9,7 @@ describe("usesBetterAuthJwt", () => {
|
|
|
9
9
|
else process.env.DATA_BACKEND = ORIGINAL;
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
it("is true for the self-host SQL
|
|
13
|
-
process.env.DATA_BACKEND = "postgres";
|
|
14
|
-
assert.equal(usesBetterAuthJwt(), true);
|
|
12
|
+
it("is true for the self-host SQL backend", () => {
|
|
15
13
|
process.env.DATA_BACKEND = "sqlite";
|
|
16
14
|
assert.equal(usesBetterAuthJwt(), true);
|
|
17
15
|
});
|
package/src/data-backend.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
2
|
+
* The self-host SQL backend (RFC 034/035/036), as opposed to the AWS DynamoDB
|
|
3
|
+
* path.
|
|
4
4
|
*/
|
|
5
|
-
export const isSelfHostSqlBackend = (): boolean =>
|
|
6
|
-
|
|
7
|
-
return backend === "postgres" || backend === "sqlite";
|
|
8
|
-
};
|
|
5
|
+
export const isSelfHostSqlBackend = (): boolean =>
|
|
6
|
+
process.env.DATA_BACKEND === "sqlite";
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
|
-
* The self-host SQL
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Guarding these paths on `=== "postgres"` alone left the sqlite deployment
|
|
17
|
-
* with no claim injection (every identity-bound request 500s) and unsigned
|
|
18
|
-
* `/content/*` URLs; both branch on this predicate instead.
|
|
9
|
+
* The self-host SQL backend authenticates requests and signs content URLs with
|
|
10
|
+
* a better-auth RS256 JWT verified at the edge and re-verified in-process, no
|
|
11
|
+
* Cognito authorizer. DynamoDB is the AWS path (Cognito claims, Lambda@Edge
|
|
12
|
+
* content guard) and is deliberately excluded.
|
|
19
13
|
*/
|
|
20
14
|
export const usesBetterAuthJwt = (): boolean => isSelfHostSqlBackend();
|
|
@@ -110,29 +110,11 @@ describe("getContentSigner", () => {
|
|
|
110
110
|
else process.env.BETTER_AUTH_SECRET = ORIGINAL_SECRET;
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
it("returns undefined
|
|
113
|
+
it("returns undefined on the AWS path (unsigned URLs)", () => {
|
|
114
114
|
delete process.env.DATA_BACKEND;
|
|
115
115
|
assert.equal(getContentSigner(), undefined);
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
it("returns a working signer in Postgres mode", () => {
|
|
119
|
-
process.env.DATA_BACKEND = "postgres";
|
|
120
|
-
process.env.BETTER_AUTH_SECRET = SECRET;
|
|
121
|
-
const signer = getContentSigner();
|
|
122
|
-
assert.ok(signer);
|
|
123
|
-
const { exp, sig } = signer(PATH_A);
|
|
124
|
-
assert.deepEqual(
|
|
125
|
-
verifyContentSignature(PATH_A, String(exp), sig, SECRET, nowSeconds()),
|
|
126
|
-
{ valid: true },
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("throws in Postgres mode when the master secret is missing (fail loud)", () => {
|
|
131
|
-
process.env.DATA_BACKEND = "postgres";
|
|
132
|
-
delete process.env.BETTER_AUTH_SECRET;
|
|
133
|
-
assert.throws(() => getContentSigner(), /BETTER_AUTH_SECRET/);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
118
|
it("returns a working signer in SQLite mode", () => {
|
|
137
119
|
process.env.DATA_BACKEND = "sqlite";
|
|
138
120
|
process.env.BETTER_AUTH_SECRET = SECRET;
|
|
@@ -2,10 +2,10 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { usesBetterAuthJwt } from "../data-backend.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Signed-URL scheme for `/content/*` on the
|
|
5
|
+
* Signed-URL scheme for `/content/*` on the self-host stack.
|
|
6
6
|
*
|
|
7
7
|
* On AWS the same bytes are guarded by CloudFront + a Lambda@Edge JWT verifier.
|
|
8
|
-
* The
|
|
8
|
+
* The self-host stack serves `/content/*` straight from the backend container,
|
|
9
9
|
* and a bearer token cannot ride along on an `<img src>` / `<a href>` content
|
|
10
10
|
* load rendered inside email HTML. So the backend signs each content URL when
|
|
11
11
|
* it hands the SPA a `BodyPartResponse.contentUrl`, and the `/content` route
|
|
@@ -37,7 +37,7 @@ export const CONTENT_URL_TTL_SECONDS = 3600;
|
|
|
37
37
|
* separation via a fixed label keeps the content-signing key distinct from the
|
|
38
38
|
* raw better-auth secret, so a compromise of one signing space does not reveal
|
|
39
39
|
* the other. Reusing `BETTER_AUTH_SECRET` as the master means no new secret has
|
|
40
|
-
* to be provisioned on the
|
|
40
|
+
* to be provisioned on the self-host stack — it is already required there.
|
|
41
41
|
*/
|
|
42
42
|
const deriveSigningKey = (masterSecret: string): Buffer =>
|
|
43
43
|
createHmac("sha256", masterSecret).update(KEY_DERIVATION_LABEL).digest();
|
|
@@ -78,19 +78,19 @@ export const createContentSigner = (
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* Build the content-URL signer for the current environment, or `undefined` when
|
|
81
|
-
* signing does not apply. Signing is enforced on the self-host SQL
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
81
|
+
* signing does not apply. Signing is enforced on the self-host SQL backend,
|
|
82
|
+
* which serves `/content/*` straight from the backend container; on AWS the
|
|
83
|
+
* Lambda@Edge JWT verifier guards `/content/*` and the URL stays unsigned so
|
|
84
|
+
* CloudFront/S3 behaviour is unchanged. Throws on the self-host backend when the
|
|
85
|
+
* master secret is missing, so a misconfigured deploy fails loud rather than
|
|
86
|
+
* shipping unsigned (unauthenticated) content URLs.
|
|
87
87
|
*/
|
|
88
88
|
export const getContentSigner = (): ContentSigner | undefined => {
|
|
89
89
|
if (!usesBetterAuthJwt()) return undefined;
|
|
90
90
|
const secret = process.env.BETTER_AUTH_SECRET;
|
|
91
91
|
if (!secret || secret.length === 0) {
|
|
92
92
|
throw new Error(
|
|
93
|
-
"
|
|
93
|
+
"the self-host SQL backend requires BETTER_AUTH_SECRET to sign content URLs",
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
return createContentSigner(secret);
|
package/src/derive/contentUrl.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface BuildContentUrlInput {
|
|
|
19
19
|
messageId: string;
|
|
20
20
|
partPath: string;
|
|
21
21
|
/**
|
|
22
|
-
* When present (
|
|
22
|
+
* When present (self-host stack), append an `exp`/`sig` signature scoped to
|
|
23
23
|
* this account's storage path. Absent on AWS, where Lambda@Edge guards the
|
|
24
24
|
* URL and the path stays unsigned. See `contentSignature.ts`.
|
|
25
25
|
*/
|
|
@@ -16,7 +16,7 @@ import type { Context } from "openapi-backend";
|
|
|
16
16
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
17
17
|
import { getMsOAuthConfig } from "../config/msoauth.js";
|
|
18
18
|
import { safeJsonParse } from "../json.js";
|
|
19
|
-
import { getClient } from "../service/
|
|
19
|
+
import { getClient } from "../service/data-client.js";
|
|
20
20
|
import type { MicrosoftOAuthOperationIds, OperationHandler } from "../types.js";
|
|
21
21
|
import { triggerAccountSyncSafe } from "./account.js";
|
|
22
22
|
import { findActiveDuplicateMailbox } from "./account-guards.js";
|
package/src/handlers/account.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
|
29
29
|
import { env } from "expect-env";
|
|
30
30
|
import type { Context } from "openapi-backend";
|
|
31
31
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
32
|
-
import { getClient } from "../service/
|
|
32
|
+
import { getClient } from "../service/data-client.js";
|
|
33
33
|
import { fireAndForget } from "../service/fire-and-forget.js";
|
|
34
34
|
import { sqsClient } from "../service/sqs.js";
|
|
35
35
|
import { triggerAccountSync } from "../service/trigger-sync.js";
|
package/src/handlers/address.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { ForbiddenError } from "@remit/data-ports/errors";
|
|
|
7
7
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
8
8
|
import type { Context } from "openapi-backend";
|
|
9
9
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
10
|
-
import { getClient } from "../service/
|
|
10
|
+
import { getClient } from "../service/data-client.js";
|
|
11
11
|
import type {
|
|
12
12
|
AddressDetailOperationIds,
|
|
13
13
|
AddressOperationIds,
|
package/src/handlers/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
|
10
10
|
import { env } from "expect-env";
|
|
11
11
|
import type { Context } from "openapi-backend";
|
|
12
12
|
import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
|
|
13
|
-
import { getClient } from "../service/
|
|
13
|
+
import { getClient } from "../service/data-client.js";
|
|
14
14
|
import { fireAndForget } from "../service/fire-and-forget.js";
|
|
15
15
|
import { sqsClient } from "../service/sqs.js";
|
|
16
16
|
import { triggerAccountSync } from "../service/trigger-sync.js";
|
package/src/handlers/filter.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { FilterScope, FilterState } from "@remit/domain-enums";
|
|
|
13
13
|
import type { AnchorPayload } from "@remit/search-service";
|
|
14
14
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
15
15
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
16
|
-
import { getClient } from "../service/
|
|
16
|
+
import { getClient } from "../service/data-client.js";
|
|
17
17
|
import { buildFilterAnchor } from "../service/filter.js";
|
|
18
18
|
import type {
|
|
19
19
|
FilterDetailOperationIds,
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
} from "@remit/api-openapi-types";
|
|
5
5
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
6
6
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
7
|
-
import { getClient } from "../service/
|
|
7
|
+
import { getClient } from "../service/data-client.js";
|
|
8
8
|
import type { FolderRoleOperationIds, OperationHandler } from "../types.js";
|
|
9
9
|
import { toAccountResponse } from "./account-guards.js";
|
|
10
10
|
import { loadAccountOverrides } from "./account-overrides.js";
|
package/src/handlers/label.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
12
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
13
13
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
14
|
-
import { getClient } from "../service/
|
|
14
|
+
import { getClient } from "../service/data-client.js";
|
|
15
15
|
import type {
|
|
16
16
|
LabelDetailOperationIds,
|
|
17
17
|
LabelOperationIds,
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
applyPendingMoveCountPrediction,
|
|
12
12
|
type PendingUnseenFlagPush,
|
|
13
13
|
} from "../derive/pendingMoveCounts.js";
|
|
14
|
-
import { getClient } from "../service/
|
|
14
|
+
import { getClient } from "../service/data-client.js";
|
|
15
15
|
import type {
|
|
16
16
|
MailboxDetailOperationIds,
|
|
17
17
|
MailboxOperationIds,
|
|
@@ -139,7 +139,7 @@ export const assertMailboxInAccount = (
|
|
|
139
139
|
* `applyPendingMoveCountPrediction`'s read-time adjustment only, never mutates
|
|
140
140
|
* stored counts (epic #1281 invariant 4). Markers are written by the
|
|
141
141
|
* imap-worker bulk sync path through `RemitClient.placementMove` on every
|
|
142
|
-
* backend, so this is a real signal on
|
|
142
|
+
* backend, so this is a real signal on the self-host stack too.
|
|
143
143
|
*/
|
|
144
144
|
const loadPendingMoves = (
|
|
145
145
|
client: Awaited<ReturnType<typeof getClient>>,
|
|
@@ -150,7 +150,7 @@ const loadPendingMoves = (
|
|
|
150
150
|
* Every pending `\Seen` flag-push marker (issue #1273) for an account —
|
|
151
151
|
* `\Flagged` (star) markers are excluded, since only read/unread state feeds
|
|
152
152
|
* `unseenCount`'s prediction. `RemitClient.flagPush` is present and WRITTEN
|
|
153
|
-
* on both backends, so this is a real signal on
|
|
153
|
+
* on both backends, so this is a real signal on the self-host stack too.
|
|
154
154
|
*/
|
|
155
155
|
const loadPendingUnseenFlagPushes = async (
|
|
156
156
|
client: Awaited<ReturnType<typeof getClient>>,
|
package/src/handlers/me.test.ts
CHANGED
package/src/handlers/me.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
|
13
13
|
import { env } from "expect-env";
|
|
14
14
|
import type { Context } from "openapi-backend";
|
|
15
15
|
import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
|
|
16
|
-
import { getClient } from "../service/
|
|
16
|
+
import { getClient } from "../service/data-client.js";
|
|
17
17
|
import { sqsClient } from "../service/sqs.js";
|
|
18
18
|
import type { MeOperationIds, OperationHandler } from "../types.js";
|
|
19
19
|
import { toVipSuggestionEntry } from "./vip-suggestions.js";
|
package/src/handlers/message.ts
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
getContentDeliveryDomain,
|
|
42
42
|
} from "../derive/contentUrl.js";
|
|
43
43
|
import { deriveSenderTrust } from "../derive/senderTrust.js";
|
|
44
|
-
import { getClient } from "../service/
|
|
44
|
+
import { getClient } from "../service/data-client.js";
|
|
45
45
|
import type {
|
|
46
46
|
MessageBulkOperationIds,
|
|
47
47
|
MessageOperationIds,
|
package/src/handlers/organize.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
|
11
11
|
import { env } from "expect-env";
|
|
12
12
|
import type { Context } from "openapi-backend";
|
|
13
13
|
import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
|
|
14
|
-
import { getClient, type RemitClient } from "../service/
|
|
14
|
+
import { getClient, type RemitClient } from "../service/data-client.js";
|
|
15
15
|
import {
|
|
16
16
|
buildOrganizeMatchDeps,
|
|
17
17
|
matchOrganize,
|
package/src/handlers/outbox.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { ForbiddenError } from "@remit/data-ports/errors";
|
|
|
8
8
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
9
9
|
import type { Context } from "openapi-backend";
|
|
10
10
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
11
|
-
import { getClient } from "../service/
|
|
11
|
+
import { getClient } from "../service/data-client.js";
|
|
12
12
|
import type {
|
|
13
13
|
OperationHandler,
|
|
14
14
|
OutboxDetailOperationIds,
|
package/src/handlers/search.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { SearchResult } from "@remit/search-service";
|
|
|
6
6
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
7
7
|
import type { Context } from "openapi-backend";
|
|
8
8
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
9
|
-
import { getClient } from "../service/
|
|
9
|
+
import { getClient } from "../service/data-client.js";
|
|
10
10
|
import {
|
|
11
11
|
isSemanticSearchUnavailable,
|
|
12
12
|
noteSemanticCapabilityAbsence,
|
package/src/handlers/sync.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { logger } from "@remit/logger-lambda";
|
|
|
3
3
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
4
4
|
import { env } from "expect-env";
|
|
5
5
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
6
|
-
import { getClient } from "../service/
|
|
6
|
+
import { getClient } from "../service/data-client.js";
|
|
7
7
|
import {
|
|
8
8
|
type FireAndForgetLogger,
|
|
9
9
|
fireAndForget,
|
package/src/handlers/thread.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
filterByOffRowCriteria,
|
|
18
18
|
hasOffRowCriteria,
|
|
19
19
|
} from "../derive/filterThreadCriteria.js";
|
|
20
|
-
import { getClient } from "../service/
|
|
20
|
+
import { getClient } from "../service/data-client.js";
|
|
21
21
|
import type {
|
|
22
22
|
OperationHandler,
|
|
23
23
|
ThreadDetailOperationIds,
|
|
@@ -9,7 +9,7 @@ import type { Context } from "openapi-backend";
|
|
|
9
9
|
import pMap from "p-map";
|
|
10
10
|
import { getAccountConfigIdFromEvent } from "../auth.js";
|
|
11
11
|
import { enrichThreadRows } from "../derive/enrichThreadRows.js";
|
|
12
|
-
import { getClient } from "../service/
|
|
12
|
+
import { getClient } from "../service/data-client.js";
|
|
13
13
|
import type { OperationHandler, UnifiedThreadOperationIds } from "../types.js";
|
|
14
14
|
import {
|
|
15
15
|
groupAccountOverrides,
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { assertLocalBypassNotInDeployedEnv } from "./auth.js";
|
|
|
14
14
|
import { usesBetterAuthJwt } from "./data-backend.js";
|
|
15
15
|
import { handleError } from "./error.js";
|
|
16
16
|
import { handlers } from "./handlers/index.js";
|
|
17
|
-
import {
|
|
17
|
+
import { authenticateSelfHostRequest } from "./jwt-auth.js";
|
|
18
18
|
import { normalizeRequest } from "./request.js";
|
|
19
19
|
import { runWithRequestContext } from "./request-context.js";
|
|
20
20
|
import { formatResponse, postResponseHandler } from "./response.js";
|
|
@@ -161,7 +161,7 @@ const rawHandler = async (event: APIGatewayProxyEvent, context: Context) =>
|
|
|
161
161
|
const origin = readOriginHeader(event.headers);
|
|
162
162
|
|
|
163
163
|
if (usesBetterAuthJwt()) {
|
|
164
|
-
const denied = await
|
|
164
|
+
const denied = await authenticateSelfHostRequest(event);
|
|
165
165
|
if (denied) return denied;
|
|
166
166
|
}
|
|
167
167
|
|
package/src/jwt-auth.test.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, test } from "node:test";
|
|
|
3
3
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
4
4
|
import {
|
|
5
5
|
_setVerifierForTest,
|
|
6
|
-
|
|
6
|
+
authenticateSelfHostRequest,
|
|
7
7
|
} from "./jwt-auth.js";
|
|
8
8
|
|
|
9
9
|
const buildEvent = (
|
|
@@ -34,7 +34,7 @@ test("valid token injects verified sub into authorizer claims", async () => {
|
|
|
34
34
|
headers: { Authorization: "Bearer good.token.here" },
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
const result = await
|
|
37
|
+
const result = await authenticateSelfHostRequest(event);
|
|
38
38
|
|
|
39
39
|
assert.equal(result, null);
|
|
40
40
|
assert.equal(event.requestContext.authorizer?.claims?.sub, "user-abc");
|
|
@@ -49,7 +49,7 @@ test("invalid token returns 401 and does not inject claims", async () => {
|
|
|
49
49
|
headers: { authorization: "Bearer bad.token" },
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
const result = await
|
|
52
|
+
const result = await authenticateSelfHostRequest(event);
|
|
53
53
|
|
|
54
54
|
assert.equal(result?.statusCode, 401);
|
|
55
55
|
assert.equal(event.requestContext.authorizer, undefined);
|
|
@@ -59,7 +59,7 @@ test("no token with a local bypass configured is allowed", async () => {
|
|
|
59
59
|
process.env.LOCAL_ACCOUNT_CONFIG_ID = "some-config-id";
|
|
60
60
|
const event = buildEvent();
|
|
61
61
|
|
|
62
|
-
const result = await
|
|
62
|
+
const result = await authenticateSelfHostRequest(event);
|
|
63
63
|
|
|
64
64
|
assert.equal(result, null);
|
|
65
65
|
});
|
|
@@ -67,7 +67,7 @@ test("no token with a local bypass configured is allowed", async () => {
|
|
|
67
67
|
test("no token and no bypass returns 401", async () => {
|
|
68
68
|
const event = buildEvent();
|
|
69
69
|
|
|
70
|
-
const result = await
|
|
70
|
+
const result = await authenticateSelfHostRequest(event);
|
|
71
71
|
|
|
72
72
|
assert.equal(result?.statusCode, 401);
|
|
73
73
|
});
|
|
@@ -82,7 +82,7 @@ test("pre-injected claims (edge tier) short-circuit verification", async () => {
|
|
|
82
82
|
} as unknown as APIGatewayProxyEvent["requestContext"],
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
const result = await
|
|
85
|
+
const result = await authenticateSelfHostRequest(event);
|
|
86
86
|
|
|
87
87
|
assert.equal(result, null);
|
|
88
88
|
assert.equal(event.requestContext.authorizer?.claims?.sub, "edge-user");
|
package/src/jwt-auth.ts
CHANGED
|
@@ -56,7 +56,7 @@ const hasLocalBypass = (): boolean =>
|
|
|
56
56
|
Boolean(process.env.LOCAL_ACCOUNT_CONFIG_ID);
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* Authenticate a
|
|
59
|
+
* Authenticate a self-host request from a better-auth RS256 JWT.
|
|
60
60
|
*
|
|
61
61
|
* On a valid token the verified `sub` is injected into the event's authorizer
|
|
62
62
|
* claims, exactly where the Cognito authorizer puts it, so every downstream
|
|
@@ -68,7 +68,7 @@ const hasLocalBypass = (): boolean =>
|
|
|
68
68
|
* When the edge tier (APISIX) has already verified and injected claims, this is
|
|
69
69
|
* a no-op — the backend trusts pre-populated claims and re-verifies otherwise.
|
|
70
70
|
*/
|
|
71
|
-
export const
|
|
71
|
+
export const authenticateSelfHostRequest = async (
|
|
72
72
|
event: APIGatewayProxyEvent,
|
|
73
73
|
): Promise<APIGatewayProxyResult | null> => {
|
|
74
74
|
const existingSub = event.requestContext?.authorizer?.claims?.sub;
|
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
OutboxMessageRepo,
|
|
26
26
|
QuarantineRepo,
|
|
27
27
|
} from "@remit/drizzle-service";
|
|
28
|
-
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
29
28
|
import { env } from "expect-env";
|
|
30
29
|
import {
|
|
31
30
|
buildSharedDeps,
|
|
@@ -34,25 +33,20 @@ import {
|
|
|
34
33
|
type RemitClientRepositories,
|
|
35
34
|
} from "./create-remit-client.js";
|
|
36
35
|
|
|
37
|
-
// The SQLite
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// rather than its own connection string, so its writes enlist in the same
|
|
44
|
-
// unit-of-work transaction and the same write queue as everything else.
|
|
36
|
+
// The SQLite adapter composition (RFC 036). Every repo runs on the single
|
|
37
|
+
// serialized connection `createSqliteDatabase` opens (D3), so a plain repo
|
|
38
|
+
// write cannot bypass serialization and join an open transaction's savepoint,
|
|
39
|
+
// and `threadMessage` takes that same shared handle, so its writes enlist in
|
|
40
|
+
// the same unit-of-work transaction and the same write queue as everything
|
|
41
|
+
// else.
|
|
45
42
|
export const buildSqliteClient = async (): Promise<RemitClient> => {
|
|
46
43
|
const sqliteDbPath = env.SQLITE_DB_PATH;
|
|
47
44
|
|
|
48
45
|
const { db } = await createSqliteDatabase(messageDataSchema, {
|
|
49
46
|
filename: sqliteDbPath,
|
|
50
47
|
});
|
|
51
|
-
const genericDb = db
|
|
52
|
-
|
|
53
|
-
const messageDataDb = db as unknown as NodePgDatabase<
|
|
54
|
-
typeof messageDataSchema
|
|
55
|
-
>;
|
|
48
|
+
const genericDb = db;
|
|
49
|
+
const messageDataDb = db;
|
|
56
50
|
|
|
57
51
|
const repositories: RemitClientRepositories = {
|
|
58
52
|
accountConfig: new AccountConfigRepo(genericDb),
|
|
@@ -85,8 +85,8 @@ export interface RemitClient {
|
|
|
85
85
|
|
|
86
86
|
// Smart Organize (RFC 034, epic #1280). Present on both backends
|
|
87
87
|
// (`FilterService`/`LabelService`/… on DynamoDB, `FilterRepo`/`LabelRepo`/…
|
|
88
|
-
//
|
|
89
|
-
// either. The
|
|
88
|
+
// relationally) so the matching pipeline and filter CRUD run unchanged on
|
|
89
|
+
// either. The relational side has no TTL reaper for expired Temporary filters;
|
|
90
90
|
// match-time correctness gates on `expiresAt` (RFC 034 Decision 1.1), never
|
|
91
91
|
// on the row still existing, so the missing reaper is housekeeping only.
|
|
92
92
|
filter: IFilterRepository;
|
|
@@ -99,7 +99,7 @@ export interface RemitClient {
|
|
|
99
99
|
label: ILabelRepository;
|
|
100
100
|
messageLabel: IMessageLabelRepository;
|
|
101
101
|
|
|
102
|
-
// Atomic write set for a message save. Present on
|
|
102
|
+
// Atomic write set for a message save. Present on the relational backend (real
|
|
103
103
|
// transaction); absent on DynamoDB, where callers fall back to per-repo
|
|
104
104
|
// writes with that backend's own (non-transactional) guarantees.
|
|
105
105
|
unitOfWork?: IUnitOfWork;
|
|
@@ -123,16 +123,16 @@ export interface RemitClient {
|
|
|
123
123
|
|
|
124
124
|
// Pending placement-move markers (issue #1271). Present on both backends
|
|
125
125
|
// (`MessagePlacementMoveService` on DynamoDB, `MessagePlacementMoveRepo` on
|
|
126
|
-
//
|
|
126
|
+
// relationally) so a caller never needs to guess which backend is active. Used
|
|
127
127
|
// for read-time count prediction (epic #1281 invariant 4) and by the
|
|
128
128
|
// account-worker cascade delete. Written by the imap-worker's bulk
|
|
129
129
|
// body-sync path through this `placementMove`, so the placement producer
|
|
130
|
-
// runs on whatever backend is active — DynamoDB
|
|
130
|
+
// runs on whatever backend is active — DynamoDB or SQLite.
|
|
131
131
|
placementMove: IMessagePlacementMoveRepository;
|
|
132
132
|
|
|
133
133
|
// Pending flag-push markers (issue #1273). Present on both backends
|
|
134
134
|
// (`MessageFlagPushService` on DynamoDB, `MessageFlagPushRepo` on
|
|
135
|
-
//
|
|
135
|
+
// relationally). Used for read-time unseenCount prediction (epic #1281
|
|
136
136
|
// invariant 4), by the account-worker cascade delete, and by the periodic
|
|
137
137
|
// per-mailbox sync tick to re-arm a marker stuck `pending`. Unlike
|
|
138
138
|
// `placementMove`, this one IS written on both backends — `flagQueue`
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, test } from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
_resetForTest,
|
|
8
|
+
getClient,
|
|
9
|
+
type RemitClient,
|
|
10
|
+
setClient,
|
|
11
|
+
} from "./data-client.js";
|
|
12
|
+
|
|
13
|
+
const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
|
|
14
|
+
"accountConfig",
|
|
15
|
+
"account",
|
|
16
|
+
"accountSetting",
|
|
17
|
+
"address",
|
|
18
|
+
"mailbox",
|
|
19
|
+
"mailboxSpecialUse",
|
|
20
|
+
"message",
|
|
21
|
+
"messageFlag",
|
|
22
|
+
"outboxMessage",
|
|
23
|
+
"threadMessage",
|
|
24
|
+
"envelope",
|
|
25
|
+
"accountExportRequest",
|
|
26
|
+
"quarantine",
|
|
27
|
+
"storage",
|
|
28
|
+
"search",
|
|
29
|
+
"secrets",
|
|
30
|
+
"bodySync",
|
|
31
|
+
"flagQueue",
|
|
32
|
+
"mailboxQueue",
|
|
33
|
+
"messageMove",
|
|
34
|
+
"outboxQueue",
|
|
35
|
+
"createConnectionScope",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
_resetForTest();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const withSqliteDbPath = async (run: () => Promise<void>): Promise<void> => {
|
|
43
|
+
const saved = process.env.SQLITE_DB_PATH;
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "remit-data-client-"));
|
|
45
|
+
process.env.SQLITE_DB_PATH = join(dir, "remit.db");
|
|
46
|
+
try {
|
|
47
|
+
await run();
|
|
48
|
+
} finally {
|
|
49
|
+
_resetForTest();
|
|
50
|
+
if (saved === undefined) delete process.env.SQLITE_DB_PATH;
|
|
51
|
+
else process.env.SQLITE_DB_PATH = saved;
|
|
52
|
+
rmSync(dir, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test("getClient() falls back to the in-package composition and constructs every service", async () => {
|
|
57
|
+
await withSqliteDbPath(async () => {
|
|
58
|
+
const client = await getClient();
|
|
59
|
+
|
|
60
|
+
for (const key of REQUIRED_KEYS) {
|
|
61
|
+
assert.ok(
|
|
62
|
+
client[key] !== undefined && client[key] !== null,
|
|
63
|
+
`RemitClient.${String(key)} must be defined on the fallback path`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
assert.equal(typeof client.createConnectionScope, "function");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("getClient() names setClient when nothing is registered and there is no database to open", async () => {
|
|
72
|
+
const saved = process.env.SQLITE_DB_PATH;
|
|
73
|
+
delete process.env.SQLITE_DB_PATH;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await assert.rejects(getClient(), /register one with setClient\(\)/);
|
|
77
|
+
} finally {
|
|
78
|
+
if (saved !== undefined) process.env.SQLITE_DB_PATH = saved;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("getClient() returns the injected client without reading the environment", async () => {
|
|
83
|
+
const saved = process.env.SQLITE_DB_PATH;
|
|
84
|
+
delete process.env.SQLITE_DB_PATH;
|
|
85
|
+
|
|
86
|
+
const injected = { account: {} } as unknown as RemitClient;
|
|
87
|
+
setClient(injected);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const client = await getClient();
|
|
91
|
+
assert.equal(client, injected);
|
|
92
|
+
} finally {
|
|
93
|
+
_resetForTest();
|
|
94
|
+
if (saved !== undefined) process.env.SQLITE_DB_PATH = saved;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RemitClient } from "./create-remit-client.js";
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
ConnectionScope,
|
|
5
|
+
RemitClient,
|
|
6
|
+
} from "./create-remit-client.js";
|
|
7
|
+
|
|
8
|
+
let clientPromise: Promise<RemitClient> | null = null;
|
|
9
|
+
let injected: RemitClient | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Register the client from the composition root. An adapter that lives outside
|
|
13
|
+
* this shared, open-core module — DynamoDB, or a Postgres adapter in the
|
|
14
|
+
* repository that deploys it — is never imported here; its composition root
|
|
15
|
+
* calls this before handling a request.
|
|
16
|
+
*/
|
|
17
|
+
export const setClient = (client: RemitClient): void => {
|
|
18
|
+
injected = client;
|
|
19
|
+
clientPromise = null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The SQLite composition this build contains, reached only by a process that
|
|
24
|
+
* registered no client of its own. The `import()` is dynamic so a bundler
|
|
25
|
+
* following the entry graph of a process that injects its client never reaches
|
|
26
|
+
* `@remit/drizzle-service` or drizzle-orm (both `external` for the Lambda
|
|
27
|
+
* esbuild build).
|
|
28
|
+
*
|
|
29
|
+
* `SQLITE_DB_PATH` is the precondition the composition needs, checked ahead of
|
|
30
|
+
* the import so its absence names `setClient` instead of failing on module
|
|
31
|
+
* resolution inside a bundle, or — worse — opening an empty database and
|
|
32
|
+
* serving an empty mailbox. It is a precondition, not a backend selection.
|
|
33
|
+
*/
|
|
34
|
+
const buildRelationalClient = async (): Promise<RemitClient> => {
|
|
35
|
+
if (!process.env.SQLITE_DB_PATH) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"no client registered — register one with setClient() from your composition root",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const composition = await import("./compose-sqlite.js");
|
|
41
|
+
return composition.buildSqliteClient();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const getClient = (): Promise<RemitClient> => {
|
|
45
|
+
if (!clientPromise) {
|
|
46
|
+
clientPromise = injected
|
|
47
|
+
? Promise.resolve(injected)
|
|
48
|
+
: buildRelationalClient();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return clientPromise;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Reset the singleton and any injected client — test use only. */
|
|
55
|
+
export const _resetForTest = (): void => {
|
|
56
|
+
clientPromise = null;
|
|
57
|
+
injected = null;
|
|
58
|
+
};
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
VectorRecord,
|
|
10
10
|
} from "@remit/search-service";
|
|
11
11
|
import { createMemoryVectorStore } from "@remit/search-service";
|
|
12
|
-
import type { RemitClient } from "./
|
|
12
|
+
import type { RemitClient } from "./data-client.js";
|
|
13
13
|
import {
|
|
14
14
|
applyOrganize,
|
|
15
15
|
matchOrganize,
|
package/src/service/organize.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
buildEmbeddingServiceFromEnv,
|
|
26
26
|
buildVectorStoreFromEnv,
|
|
27
27
|
} from "@remit/search-service/from-env";
|
|
28
|
-
import type { RemitClient } from "./
|
|
28
|
+
import type { RemitClient } from "./data-client.js";
|
|
29
29
|
import { noteSemanticCapabilityAbsence } from "./semantic-capability.js";
|
|
30
30
|
|
|
31
31
|
/**
|
|
@@ -33,14 +33,11 @@ describe("noteSemanticCapabilityAbsence", () => {
|
|
|
33
33
|
_resetSemanticCapabilityForTest();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("absorbs a missing-module failure on the self-host SQL
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), true);
|
|
42
|
-
assert.equal(isSemanticSearchUnavailable(), true);
|
|
43
|
-
}
|
|
36
|
+
it("absorbs a missing-module failure on the self-host SQL backend and remembers it", () => {
|
|
37
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
38
|
+
assert.equal(isSemanticSearchUnavailable(), false);
|
|
39
|
+
assert.equal(noteSemanticCapabilityAbsence(moduleNotFound()), true);
|
|
40
|
+
assert.equal(isSemanticSearchUnavailable(), true);
|
|
44
41
|
});
|
|
45
42
|
|
|
46
43
|
it("absorbs a dlopen failure (musl loading a glibc extension)", () => {
|
|
@@ -53,16 +50,13 @@ describe("noteSemanticCapabilityAbsence", () => {
|
|
|
53
50
|
});
|
|
54
51
|
|
|
55
52
|
it("absorbs an embedding-model load failure (e2e-dev from source, HuggingFace fetch failed) and remembers it", () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
|
|
61
|
-
assert.equal(isSemanticSearchUnavailable(), true);
|
|
62
|
-
}
|
|
53
|
+
process.env.DATA_BACKEND = "sqlite";
|
|
54
|
+
assert.equal(isSemanticSearchUnavailable(), false);
|
|
55
|
+
assert.equal(noteSemanticCapabilityAbsence(modelUnavailable()), true);
|
|
56
|
+
assert.equal(isSemanticSearchUnavailable(), true);
|
|
63
57
|
});
|
|
64
58
|
|
|
65
|
-
it("rethrows genuine query errors on the self-host SQL
|
|
59
|
+
it("rethrows genuine query errors on the self-host SQL backend", () => {
|
|
66
60
|
process.env.DATA_BACKEND = "sqlite";
|
|
67
61
|
assert.equal(
|
|
68
62
|
noteSemanticCapabilityAbsence(new Error("SQLITE_BUSY")),
|
|
@@ -35,7 +35,7 @@ import { isSelfHostSqlBackend } from "../data-backend.js";
|
|
|
35
35
|
* the FTS/literal engine is the primary search surface on these profiles and is
|
|
36
36
|
* unaffected. The absence is remembered so subsequent requests short-circuit.
|
|
37
37
|
*
|
|
38
|
-
* Scoped to the self-host SQL
|
|
38
|
+
* Scoped to the self-host SQL backend: on AWS the pipeline (Bedrock +
|
|
39
39
|
* S3 Vectors) is bundled, so a module-resolution failure there is a broken
|
|
40
40
|
* deploy and must keep failing loud. Falling back to the FTS engine here
|
|
41
41
|
* instead was considered and rejected — it would fabricate relevance scores and
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AccountConfigRepo,
|
|
3
|
-
AccountExportRequestRepo,
|
|
4
|
-
AccountRepo,
|
|
5
|
-
AccountSettingRepo,
|
|
6
|
-
AddressRepo,
|
|
7
|
-
DrizzleEnvelopeRepository,
|
|
8
|
-
DrizzleFilterAnchorTransaction,
|
|
9
|
-
DrizzleMessageFlagRepository,
|
|
10
|
-
DrizzleMessageRepository,
|
|
11
|
-
DrizzleThreadMessageRepository,
|
|
12
|
-
DrizzleUnitOfWork,
|
|
13
|
-
FilterAnchorRepo,
|
|
14
|
-
FilterRepo,
|
|
15
|
-
LabelRepo,
|
|
16
|
-
MailboxLockRepo,
|
|
17
|
-
MailboxRepo,
|
|
18
|
-
MailboxSpecialUseRepo,
|
|
19
|
-
MessageFlagPushRepo,
|
|
20
|
-
MessageLabelRepo,
|
|
21
|
-
MessagePlacementMoveRepo,
|
|
22
|
-
messageDataSchema,
|
|
23
|
-
OrganizeJobRequestRepo,
|
|
24
|
-
OutboxMessageRepo,
|
|
25
|
-
QuarantineRepo,
|
|
26
|
-
} from "@remit/drizzle-service";
|
|
27
|
-
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
28
|
-
import { env } from "expect-env";
|
|
29
|
-
import {
|
|
30
|
-
buildSharedDeps,
|
|
31
|
-
createRemitClient,
|
|
32
|
-
type RemitClient,
|
|
33
|
-
type RemitClientRepositories,
|
|
34
|
-
} from "./create-remit-client.js";
|
|
35
|
-
|
|
36
|
-
export const buildPostgresClient = (): RemitClient => {
|
|
37
|
-
const pgConnectionUrl = env.PG_CONNECTION_URL;
|
|
38
|
-
|
|
39
|
-
// One drizzle db instance shared across repos.
|
|
40
|
-
// The schema is registered for message-data tables; i4 repos use the same
|
|
41
|
-
// underlying connection and only need the builder API (no relational queries).
|
|
42
|
-
const db = drizzle(pgConnectionUrl, { schema: messageDataSchema });
|
|
43
|
-
const genericDb = db as unknown as NodePgDatabase<Record<string, unknown>>;
|
|
44
|
-
|
|
45
|
-
const messageDataDb = db as unknown as NodePgDatabase<
|
|
46
|
-
typeof messageDataSchema
|
|
47
|
-
>;
|
|
48
|
-
|
|
49
|
-
const repositories: RemitClientRepositories = {
|
|
50
|
-
accountConfig: new AccountConfigRepo(genericDb),
|
|
51
|
-
account: new AccountRepo(genericDb),
|
|
52
|
-
accountSetting: new AccountSettingRepo(genericDb),
|
|
53
|
-
address: new AddressRepo(genericDb),
|
|
54
|
-
mailbox: new MailboxRepo(genericDb),
|
|
55
|
-
mailboxSpecialUse: new MailboxSpecialUseRepo(genericDb),
|
|
56
|
-
mailboxLock: new MailboxLockRepo(genericDb),
|
|
57
|
-
message: new DrizzleMessageRepository(messageDataDb),
|
|
58
|
-
messageFlag: new DrizzleMessageFlagRepository(messageDataDb),
|
|
59
|
-
outboxMessage: new OutboxMessageRepo(genericDb),
|
|
60
|
-
threadMessage: new DrizzleThreadMessageRepository(pgConnectionUrl),
|
|
61
|
-
envelope: new DrizzleEnvelopeRepository(messageDataDb),
|
|
62
|
-
accountExportRequest: new AccountExportRequestRepo(genericDb),
|
|
63
|
-
quarantine: new QuarantineRepo(genericDb),
|
|
64
|
-
organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
|
|
65
|
-
placementMove: new MessagePlacementMoveRepo(genericDb),
|
|
66
|
-
flagPush: new MessageFlagPushRepo(genericDb),
|
|
67
|
-
filter: new FilterRepo(genericDb),
|
|
68
|
-
filterAnchor: new FilterAnchorRepo(genericDb),
|
|
69
|
-
filterAnchorTransaction: new DrizzleFilterAnchorTransaction(genericDb),
|
|
70
|
-
label: new LabelRepo(genericDb),
|
|
71
|
-
messageLabel: new MessageLabelRepo(genericDb),
|
|
72
|
-
unitOfWork: new DrizzleUnitOfWork(messageDataDb),
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return createRemitClient({ repositories, ...buildSharedDeps() });
|
|
76
|
-
};
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
_resetForTest,
|
|
5
|
-
getClient,
|
|
6
|
-
type RemitClient,
|
|
7
|
-
setClient,
|
|
8
|
-
} from "./dynamodb.js";
|
|
9
|
-
|
|
10
|
-
const REQUIRED_KEYS: ReadonlyArray<keyof RemitClient> = [
|
|
11
|
-
"accountConfig",
|
|
12
|
-
"account",
|
|
13
|
-
"accountSetting",
|
|
14
|
-
"address",
|
|
15
|
-
"mailbox",
|
|
16
|
-
"mailboxSpecialUse",
|
|
17
|
-
"message",
|
|
18
|
-
"messageFlag",
|
|
19
|
-
"outboxMessage",
|
|
20
|
-
"threadMessage",
|
|
21
|
-
"envelope",
|
|
22
|
-
"accountExportRequest",
|
|
23
|
-
"quarantine",
|
|
24
|
-
"storage",
|
|
25
|
-
"search",
|
|
26
|
-
"secrets",
|
|
27
|
-
"bodySync",
|
|
28
|
-
"flagQueue",
|
|
29
|
-
"mailboxQueue",
|
|
30
|
-
"messageMove",
|
|
31
|
-
"outboxQueue",
|
|
32
|
-
"createConnectionScope",
|
|
33
|
-
] as const;
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
_resetForTest();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("getClient() with DATA_BACKEND=postgres constructs all services without throwing", async () => {
|
|
40
|
-
const savedBackend = process.env.DATA_BACKEND;
|
|
41
|
-
const savedPgUrl = process.env.PG_CONNECTION_URL;
|
|
42
|
-
|
|
43
|
-
process.env.DATA_BACKEND = "postgres";
|
|
44
|
-
process.env.PG_CONNECTION_URL =
|
|
45
|
-
"postgresql://remit:remit@localhost:5432/remit_test";
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const client = await getClient();
|
|
49
|
-
|
|
50
|
-
for (const key of REQUIRED_KEYS) {
|
|
51
|
-
assert.ok(
|
|
52
|
-
client[key] !== undefined && client[key] !== null,
|
|
53
|
-
`RemitClient.${key} must be defined on the postgres path`,
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
assert.equal(typeof client.createConnectionScope, "function");
|
|
58
|
-
} finally {
|
|
59
|
-
_resetForTest();
|
|
60
|
-
if (savedBackend === undefined) {
|
|
61
|
-
delete process.env.DATA_BACKEND;
|
|
62
|
-
} else {
|
|
63
|
-
process.env.DATA_BACKEND = savedBackend;
|
|
64
|
-
}
|
|
65
|
-
if (savedPgUrl === undefined) {
|
|
66
|
-
delete process.env.PG_CONNECTION_URL;
|
|
67
|
-
} else {
|
|
68
|
-
process.env.PG_CONNECTION_URL = savedPgUrl;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("getClient() without DATA_BACKEND throws until a client is registered", () => {
|
|
74
|
-
const savedBackend = process.env.DATA_BACKEND;
|
|
75
|
-
delete process.env.DATA_BACKEND;
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
assert.throws(() => getClient(), /no DynamoDB client registered/);
|
|
79
|
-
} finally {
|
|
80
|
-
if (savedBackend !== undefined) process.env.DATA_BACKEND = savedBackend;
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("getClient() without DATA_BACKEND returns the injected client", async () => {
|
|
85
|
-
const savedBackend = process.env.DATA_BACKEND;
|
|
86
|
-
delete process.env.DATA_BACKEND;
|
|
87
|
-
|
|
88
|
-
const injected = { account: {} } as unknown as RemitClient;
|
|
89
|
-
setClient(injected);
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
const client = await getClient();
|
|
93
|
-
assert.equal(client, injected);
|
|
94
|
-
} finally {
|
|
95
|
-
_resetForTest();
|
|
96
|
-
if (savedBackend !== undefined) process.env.DATA_BACKEND = savedBackend;
|
|
97
|
-
}
|
|
98
|
-
});
|
package/src/service/dynamodb.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import type { RemitClient } from "./create-remit-client.js";
|
|
2
|
-
|
|
3
|
-
export type {
|
|
4
|
-
ConnectionScope,
|
|
5
|
-
RemitClient,
|
|
6
|
-
} from "./create-remit-client.js";
|
|
7
|
-
|
|
8
|
-
let clientPromise: Promise<RemitClient> | null = null;
|
|
9
|
-
let injected: RemitClient | null = null;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Register the DynamoDB-backed client from the composition root. The DynamoDB
|
|
13
|
-
* composition lives outside this shared, open-core module and is never imported
|
|
14
|
-
* here. Every DynamoDB entry point calls this before handling a request. The
|
|
15
|
-
* relational backends compose in-package below and never touch this seam.
|
|
16
|
-
*/
|
|
17
|
-
export const setClient = (client: RemitClient): void => {
|
|
18
|
-
injected = client;
|
|
19
|
-
clientPromise = null;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// The API process and the imap-worker share this composition root. The
|
|
23
|
-
// relational backends are loaded through a lazy `import()` of their own
|
|
24
|
-
// in-package composition module, so the module a given deploy never runs never
|
|
25
|
-
// enters its graph: `@remit/drizzle-service`/`drizzle-orm` stay out of a
|
|
26
|
-
// Lambda bundle (both `external` for the Lambda esbuild build;
|
|
27
|
-
// `remit-lambda-bundles.test.ts` enforces this). The DynamoDB backend is
|
|
28
|
-
// injected by the composition root through `setClient`, so this module carries
|
|
29
|
-
// no import of the DynamoDB composition.
|
|
30
|
-
export const getClient = (): Promise<RemitClient> => {
|
|
31
|
-
if (!clientPromise) {
|
|
32
|
-
if (process.env.DATA_BACKEND === "postgres") {
|
|
33
|
-
clientPromise = import("./compose-postgres.js").then((m) =>
|
|
34
|
-
m.buildPostgresClient(),
|
|
35
|
-
);
|
|
36
|
-
} else if (process.env.DATA_BACKEND === "sqlite") {
|
|
37
|
-
clientPromise = import("./compose-sqlite.js").then((m) =>
|
|
38
|
-
m.buildSqliteClient(),
|
|
39
|
-
);
|
|
40
|
-
} else {
|
|
41
|
-
if (!injected) {
|
|
42
|
-
throw new Error(
|
|
43
|
-
"no DynamoDB client registered — register one with setClient() from your composition root",
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
clientPromise = Promise.resolve(injected);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return clientPromise;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/** Reset the singleton and any injected client — test use only. */
|
|
54
|
-
export const _resetForTest = (): void => {
|
|
55
|
-
clientPromise = null;
|
|
56
|
-
injected = null;
|
|
57
|
-
};
|