@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.88",
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
+ });
@@ -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"