@remit/backend 0.0.88 → 0.0.89
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 +3 -1
- package/scripts/config-save.ts +128 -0
- package/src/handlers/config.ts +18 -0
- package/src/service/export-identity.ts +39 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/backend",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.89",
|
|
4
4
|
"description": "Remit Mail Inspector API backend",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "",
|
|
@@ -52,6 +52,8 @@
|
|
|
52
52
|
"tsx": "*"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
+
"@remit/config-format": "*",
|
|
56
|
+
"@remit/config-transfer": "*",
|
|
55
57
|
"@remit/data-ports": "*",
|
|
56
58
|
"@remit/domain-enums": "*",
|
|
57
59
|
"@remit/api-openapi-types": "*",
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { readConfigForExport } from "@remit/config-transfer";
|
|
2
|
+
import { env } from "expect-env";
|
|
3
|
+
// Reached by module path rather than through either package's entry point, the
|
|
4
|
+
// same way the migrate entrypoint reaches its repairs: this file is bundled by
|
|
5
|
+
// esbuild, and a barrel import would drag every repository and the native
|
|
6
|
+
// driver behind it into a script that reads one table.
|
|
7
|
+
import { auth_user } from "../../auth-service/src/schema/auth-schema-sqlite.js";
|
|
8
|
+
import { createSqliteDatabase } from "../../drizzle-service/src/sqlite-client.js";
|
|
9
|
+
import { deriveAccountConfigId } from "../src/auth.js";
|
|
10
|
+
import { getClient } from "../src/service/data-client.js";
|
|
11
|
+
import { exportIdentity } from "../src/service/export-identity.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `remit config save` (issue #1021). Writes one configuration out as a
|
|
15
|
+
* versioned JSON document on stdout, over the same reader the export endpoint
|
|
16
|
+
* uses. Ships as an alternate entrypoint in the backend image — "the backend
|
|
17
|
+
* image with a command", the shape `migrate.mjs` and `backfill-list-id.mjs`
|
|
18
|
+
* already use — because the operator runs it before a migration drops the
|
|
19
|
+
* database, with no browser and no session to authenticate.
|
|
20
|
+
*
|
|
21
|
+
* The document goes to stdout and never to a path inside the container: the
|
|
22
|
+
* wrapper redirects it to a host file, so the file lands where the operator can
|
|
23
|
+
* see it and with their ownership, rather than root-owned inside a volume.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const USAGE = `Usage: node config-save.mjs [--user <email>]
|
|
27
|
+
|
|
28
|
+
Writes the configuration as a versioned JSON document to stdout. Contains no
|
|
29
|
+
credential: each account records which one it will need back instead.
|
|
30
|
+
|
|
31
|
+
--user <email> Which sign-in to export. Optional on an instance that holds
|
|
32
|
+
exactly one configuration.
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
interface Options {
|
|
36
|
+
user: string | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parseArguments = (argv: readonly string[]): Options => {
|
|
40
|
+
const options: Options = { user: undefined };
|
|
41
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
42
|
+
const argument = argv[index];
|
|
43
|
+
if (argument === "--user") {
|
|
44
|
+
const value = argv[index + 1];
|
|
45
|
+
if (value === undefined || value.startsWith("--")) {
|
|
46
|
+
throw new Error("--user needs an email address");
|
|
47
|
+
}
|
|
48
|
+
options.user = value;
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`unknown option '${argument}'`);
|
|
53
|
+
}
|
|
54
|
+
return options;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The configuration behind a sign-in. Identity lives in better-auth's own
|
|
59
|
+
* tables in the same database file, and the configuration id derives from the
|
|
60
|
+
* user id, so the email on the sign-in screen is the one thing an operator can
|
|
61
|
+
* be expected to know.
|
|
62
|
+
*/
|
|
63
|
+
const accountConfigIdForUser = async (email: string): Promise<string> => {
|
|
64
|
+
const { db, close } = await createSqliteDatabase(
|
|
65
|
+
{ auth_user },
|
|
66
|
+
{ filename: env.SQLITE_DB_PATH },
|
|
67
|
+
);
|
|
68
|
+
try {
|
|
69
|
+
const users = await db
|
|
70
|
+
.select({ id: auth_user.id, email: auth_user.email })
|
|
71
|
+
.from(auth_user);
|
|
72
|
+
// Matched in JS rather than in the query: an address is
|
|
73
|
+
// case-insensitive on the part that matters, and which collation the
|
|
74
|
+
// column happens to carry is not something an operator should have to
|
|
75
|
+
// know before their own email matches.
|
|
76
|
+
const wanted = email.toLowerCase();
|
|
77
|
+
const user = users.find((row) => row.email.toLowerCase() === wanted);
|
|
78
|
+
if (!user) throw new Error(`no user signs in as ${email}`);
|
|
79
|
+
return deriveAccountConfigId(user.id);
|
|
80
|
+
} finally {
|
|
81
|
+
await close();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const soleAccountConfigId = async (
|
|
86
|
+
listAll: () => Promise<Array<{ accountConfigId: string }>>,
|
|
87
|
+
): Promise<string> => {
|
|
88
|
+
const configs = await listAll();
|
|
89
|
+
const only = configs[0];
|
|
90
|
+
if (!only) throw new Error("this instance holds no configuration to export");
|
|
91
|
+
if (configs.length > 1) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`this instance holds ${configs.length} configurations — name one with --user <email>`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return only.accountConfigId;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const run = async (): Promise<void> => {
|
|
100
|
+
const argv = process.argv.slice(2);
|
|
101
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
102
|
+
process.stdout.write(USAGE);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const options = parseArguments(argv);
|
|
107
|
+
const client = await getClient();
|
|
108
|
+
const accountConfigId = options.user
|
|
109
|
+
? await accountConfigIdForUser(options.user)
|
|
110
|
+
: await soleAccountConfigId(() => client.accountConfig.listAll());
|
|
111
|
+
|
|
112
|
+
const document = await readConfigForExport(
|
|
113
|
+
client,
|
|
114
|
+
accountConfigId,
|
|
115
|
+
exportIdentity(),
|
|
116
|
+
);
|
|
117
|
+
process.stdout.write(`${JSON.stringify(document, null, "\t")}\n`);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// No `process.exit` on the way out: the document is written to stdout, and
|
|
121
|
+
// stdout is a pipe under `compose run`, so exiting drops whatever write is
|
|
122
|
+
// still pending. Setting the code and letting the process end delivers it.
|
|
123
|
+
await run().catch((error: unknown) => {
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`config save: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
126
|
+
);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
});
|
package/src/handlers/config.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
AccountConfigResponse,
|
|
4
4
|
ConfigDescriptionResponse,
|
|
5
5
|
} from "@remit/api-openapi-types";
|
|
6
|
+
import type { ReaderConfigDocument } from "@remit/config-format";
|
|
7
|
+
import { readConfigForExport } from "@remit/config-transfer";
|
|
6
8
|
import type { AccountConfigItem, MailboxItem } from "@remit/data-ports";
|
|
7
9
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
8
10
|
import { logger } from "@remit/logger-lambda";
|
|
@@ -11,6 +13,7 @@ import { env } from "expect-env";
|
|
|
11
13
|
import type { Context } from "openapi-backend";
|
|
12
14
|
import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
|
|
13
15
|
import { getClient } from "../service/data-client.js";
|
|
16
|
+
import { exportIdentity } from "../service/export-identity.js";
|
|
14
17
|
import { fireAndForget } from "../service/fire-and-forget.js";
|
|
15
18
|
import { sqsClient } from "../service/sqs.js";
|
|
16
19
|
import { triggerAccountSync } from "../service/trigger-sync.js";
|
|
@@ -216,4 +219,19 @@ export const ConfigOperations: Record<
|
|
|
216
219
|
),
|
|
217
220
|
};
|
|
218
221
|
},
|
|
222
|
+
|
|
223
|
+
ConfigOperations_exportConfig: async (
|
|
224
|
+
_context: Context,
|
|
225
|
+
...args: unknown[]
|
|
226
|
+
): Promise<{ schemaVersion: number; document: ReaderConfigDocument }> => {
|
|
227
|
+
const event = args[0] as APIGatewayProxyEvent;
|
|
228
|
+
const accountConfigId = getAccountConfigIdFromEvent(event);
|
|
229
|
+
const client = await getClient();
|
|
230
|
+
const document = await readConfigForExport(
|
|
231
|
+
client,
|
|
232
|
+
accountConfigId,
|
|
233
|
+
exportIdentity(),
|
|
234
|
+
);
|
|
235
|
+
return { schemaVersion: document.schemaVersion, document };
|
|
236
|
+
},
|
|
219
237
|
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ConfigExportIdentity } from "@remit/config-transfer";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONTROL_DIR = "/data/control";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The version a file says it was written by. The updater owns this fact: it
|
|
9
|
+
* reads the tag from `.env` and writes it into `state.json`, which is the only
|
|
10
|
+
* honest source across an update that rewrites that tag underneath a
|
|
11
|
+
* long-lived process. With no state file there is nothing authoritative to
|
|
12
|
+
* stamp, and an export says so rather than inventing a version an importing
|
|
13
|
+
* reader would then trust.
|
|
14
|
+
*/
|
|
15
|
+
const runningVersion = (): string => {
|
|
16
|
+
const dir = process.env.REMIT_UPDATE_CONTROL_DIR ?? DEFAULT_CONTROL_DIR;
|
|
17
|
+
let raw: string;
|
|
18
|
+
try {
|
|
19
|
+
raw = readFileSync(join(dir, "state.json"), "utf8");
|
|
20
|
+
} catch {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
const state: unknown = JSON.parse(raw);
|
|
24
|
+
if (typeof state !== "object" || state === null) return "unknown";
|
|
25
|
+
const version = (state as { currentVersion?: unknown }).currentVersion;
|
|
26
|
+
return typeof version === "string" && version.length > 0
|
|
27
|
+
? version
|
|
28
|
+
: "unknown";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Who wrote a configuration file, and from where. Provenance only. */
|
|
32
|
+
export const exportIdentity = (
|
|
33
|
+
now: Date = new Date(),
|
|
34
|
+
): ConfigExportIdentity => ({
|
|
35
|
+
app: "reader",
|
|
36
|
+
version: runningVersion(),
|
|
37
|
+
exportedAt: now.toISOString(),
|
|
38
|
+
instance: process.env.PUBLIC_ORIGIN ?? "",
|
|
39
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type OperationIds =
|
|
|
12
12
|
| "MeOperations_getExport"
|
|
13
13
|
| "MeOperations_listQuarantine"
|
|
14
14
|
| "ConfigOperations_getConfig"
|
|
15
|
+
| "ConfigOperations_exportConfig"
|
|
15
16
|
| "SystemOperations_getSystemUpdate"
|
|
16
17
|
| "SystemOperations_applySystemUpdate"
|
|
17
18
|
| "AccountOperations_createAccount"
|