@remit/backend 0.0.88 → 0.0.90

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.90",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -19,6 +19,10 @@
19
19
  "types": "./src/service/create-remit-client.ts",
20
20
  "default": "./src/service/create-remit-client.ts"
21
21
  },
22
+ "./folder-role-appointments": {
23
+ "types": "./src/handlers/folder-role-appointments.ts",
24
+ "default": "./src/handlers/folder-role-appointments.ts"
25
+ },
22
26
  "./organize": {
23
27
  "types": "./src/service/organize.ts",
24
28
  "default": "./src/service/organize.ts"
@@ -52,6 +56,8 @@
52
56
  "tsx": "*"
53
57
  },
54
58
  "dependencies": {
59
+ "@remit/config-format": "*",
60
+ "@remit/config-transfer": "*",
55
61
  "@remit/data-ports": "*",
56
62
  "@remit/domain-enums": "*",
57
63
  "@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
+ });
@@ -2,15 +2,25 @@ import type { SQSClient } from "@aws-sdk/client-sqs";
2
2
  import type {
3
3
  AccountConfigResponse,
4
4
  ConfigDescriptionResponse,
5
+ ConfigImportReport,
5
6
  } from "@remit/api-openapi-types";
7
+ import type { ReaderConfigDocument } from "@remit/config-format";
8
+ import {
9
+ importConfig,
10
+ pendingImportOf,
11
+ readConfigForExport,
12
+ } from "@remit/config-transfer";
6
13
  import type { AccountConfigItem, MailboxItem } from "@remit/data-ports";
7
- import { NotFoundError } from "@remit/data-ports/errors";
14
+ import { ConfigNotEmptyError, NotFoundError } from "@remit/data-ports/errors";
15
+ import type { CanonicalMailboxRoleValue } from "@remit/data-ports/folder-role";
8
16
  import { logger } from "@remit/logger-lambda";
9
17
  import type { APIGatewayProxyEvent } from "aws-lambda";
10
18
  import { env } from "expect-env";
11
19
  import type { Context } from "openapi-backend";
12
20
  import { getAccountConfigIdFromEvent, getSubFromEvent } from "../auth.js";
21
+ import { embedAnchorText } from "../service/config-import.js";
13
22
  import { getClient } from "../service/data-client.js";
23
+ import { exportIdentity } from "../service/export-identity.js";
14
24
  import { fireAndForget } from "../service/fire-and-forget.js";
15
25
  import { sqsClient } from "../service/sqs.js";
16
26
  import { triggerAccountSync } from "../service/trigger-sync.js";
@@ -27,6 +37,7 @@ import {
27
37
  import {
28
38
  groupFolderAppointmentsByAccount,
29
39
  resolveFolderAppointments,
40
+ writeFolderRoleAppointment,
30
41
  } from "./folder-role-appointments.js";
31
42
 
32
43
  type StructuredLog = (fields: Record<string, unknown>, message: string) => void;
@@ -201,8 +212,15 @@ export const ConfigOperations: Record<
201
212
  activeAccounts.map((acc) => acc.accountId),
202
213
  );
203
214
 
215
+ // An import that named folders IMAP had not produced yet rides the config
216
+ // read rather than a route of its own, so nothing has to poll for it.
217
+ const pendingImport = pendingImportOf(
218
+ await client.configImport.listByAccountConfig(accountConfigId),
219
+ );
220
+
204
221
  return {
205
222
  accountConfig: toAccountConfigResponse(accountConfig),
223
+ ...(pendingImport ? { pendingImport } : {}),
206
224
  accounts: activeAccounts.map((acc) =>
207
225
  toAccountResponse(
208
226
  acc,
@@ -216,4 +234,74 @@ export const ConfigOperations: Record<
216
234
  ),
217
235
  };
218
236
  },
237
+
238
+ ConfigOperations_exportConfig: async (
239
+ _context: Context,
240
+ ...args: unknown[]
241
+ ): Promise<{ schemaVersion: number; document: ReaderConfigDocument }> => {
242
+ const event = args[0] as APIGatewayProxyEvent;
243
+ const accountConfigId = getAccountConfigIdFromEvent(event);
244
+ const client = await getClient();
245
+ const document = await readConfigForExport(
246
+ client,
247
+ accountConfigId,
248
+ exportIdentity(),
249
+ );
250
+ return { schemaVersion: document.schemaVersion, document };
251
+ },
252
+
253
+ ConfigOperations_importConfig: async (
254
+ context: Context,
255
+ ...args: unknown[]
256
+ ): Promise<ConfigImportReport> => {
257
+ const event = args[0] as APIGatewayProxyEvent;
258
+ const accountConfigId = getAccountConfigIdFromEvent(event);
259
+ const body = (context.request.requestBody ?? {}) as {
260
+ mode?: "validate" | "apply";
261
+ onExisting?: "abort" | "merge";
262
+ document?: unknown;
263
+ };
264
+ const client = await getClient();
265
+
266
+ const outcome = await importConfig(
267
+ {
268
+ repositories: client,
269
+ // Passed through as-is, undefined included: a backend with no
270
+ // cross-entity transaction writes without one, and the report words
271
+ // what survived a failure from whether this is here.
272
+ transaction: client.writeSet,
273
+ appointFolderRole: (
274
+ configId,
275
+ accountId,
276
+ role,
277
+ mailboxId,
278
+ lastKnownPath,
279
+ ) =>
280
+ writeFolderRoleAppointment(
281
+ client.accountSetting,
282
+ configId,
283
+ accountId,
284
+ role as CanonicalMailboxRoleValue,
285
+ mailboxId,
286
+ lastKnownPath,
287
+ ),
288
+ embedAnchor: embedAnchorText,
289
+ },
290
+ {
291
+ accountConfigId,
292
+ userId: getSubFromEvent(event) ?? accountConfigId,
293
+ document: body.document,
294
+ mode: body.mode ?? "validate",
295
+ onExisting: body.onExisting ?? "abort",
296
+ },
297
+ );
298
+
299
+ if (outcome.outcome === "conflict") {
300
+ throw new ConfigNotEmptyError(
301
+ outcome.conflict.message,
302
+ outcome.conflict.details,
303
+ );
304
+ }
305
+ return outcome.report;
306
+ },
219
307
  };
@@ -3,10 +3,11 @@ import type {
3
3
  FilterResponse,
4
4
  UpdateFilterInput as UpdateFilterRequestBody,
5
5
  } from "@remit/api-openapi-types";
6
- import type {
7
- FilterItem,
8
- IFilterAnchorTransaction,
9
- UpdateFilterInput,
6
+ import {
7
+ deriveFilterTtl,
8
+ type FilterItem,
9
+ type IFilterAnchorTransaction,
10
+ type UpdateFilterInput,
10
11
  } from "@remit/data-ports";
11
12
  import { BadRequestError } from "@remit/data-ports/errors";
12
13
  import { FilterScope, FilterState } from "@remit/domain-enums";
@@ -41,24 +42,6 @@ export interface FilterCrudDeps {
41
42
  ): Promise<AnchorPayload | null>;
42
43
  }
43
44
 
44
- /**
45
- * Epoch-seconds `ttl` derived from `expiresAt`, set only for a `Temporary`
46
- * filter (RFC 034 Decision 1.3). A `Standing` filter never carries `ttl` — the
47
- * reserved table-wide TTL attribute must stay absent, or the row would be swept
48
- * (Decision 1.4).
49
- */
50
- export const deriveFilterTtl = (
51
- scope: string,
52
- expiresAt: string | undefined,
53
- ): number | undefined => {
54
- if (scope !== FilterScope.Temporary || !expiresAt) return undefined;
55
- const ms = new Date(expiresAt).getTime();
56
- if (Number.isNaN(ms)) {
57
- throw new BadRequestError(`Invalid expiresAt: ${expiresAt}`);
58
- }
59
- return Math.floor(ms / 1000);
60
- };
61
-
62
45
  /**
63
46
  * Reduce a PATCH body to the fields a filter update may set (RFC 034, reader
64
47
  * #266). Preserves absence: a key not present in the body is not present in
@@ -4,6 +4,7 @@ import {
4
4
  AccountRepo,
5
5
  AccountSettingRepo,
6
6
  AddressRepo,
7
+ ConfigImportRepo,
7
8
  createSqliteDatabase,
8
9
  DrizzleEnvelopeRepository,
9
10
  DrizzleFilterAnchorTransaction,
@@ -25,6 +26,7 @@ import {
25
26
  OutboxAttachmentRepo,
26
27
  OutboxMessageRepo,
27
28
  QuarantineRepo,
29
+ runInTransaction,
28
30
  SenderSignerStandingRepo,
29
31
  } from "@remit/drizzle-service";
30
32
  import { env } from "expect-env";
@@ -65,6 +67,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
65
67
  threadMessage: new DrizzleThreadMessageRepository(genericDb),
66
68
  envelope: new DrizzleEnvelopeRepository(messageDataDb),
67
69
  accountExportRequest: new AccountExportRequestRepo(genericDb),
70
+ configImport: new ConfigImportRepo(genericDb),
68
71
  quarantine: new QuarantineRepo(genericDb),
69
72
  organizeJobRequest: new OrganizeJobRequestRepo(genericDb),
70
73
  placementMove: new MessagePlacementMoveRepo(genericDb),
@@ -76,6 +79,7 @@ export const buildSqliteClient = async (): Promise<RemitClient> => {
76
79
  messageLabel: new MessageLabelRepo(genericDb),
77
80
  senderSignerStanding: new SenderSignerStandingRepo(genericDb),
78
81
  unitOfWork: new DrizzleUnitOfWork(messageDataDb),
82
+ writeSet: (run) => runInTransaction(genericDb, () => run()),
79
83
  };
80
84
 
81
85
  return createRemitClient({ repositories, ...buildSharedDeps() });
@@ -0,0 +1,39 @@
1
+ import type { EmbedAnchor } from "@remit/config-transfer";
2
+ import { logger } from "@remit/logger-lambda";
3
+ import { buildEmbeddingServiceFromEnv } from "@remit/search-service/from-env";
4
+ import { isSemanticCapabilityAbsence } from "./semantic-capability.js";
5
+
6
+ type Embedder = {
7
+ embed: (texts: string[]) => Promise<number[][]>;
8
+ readonly embeddingId: string;
9
+ };
10
+
11
+ let cached: Embedder | null = null;
12
+
13
+ /**
14
+ * Re-embed a filter's anchor from the source text its file carries. The vector
15
+ * never travels — it is a function of the model that produced it — so this is
16
+ * the one thing an import computes rather than copies.
17
+ *
18
+ * A deployment that ships no vector pipeline answers `undefined` rather than
19
+ * failing the import: the anchor then lands stamped as un-embedded, and the
20
+ * same lazy repair that handles a model migration builds its vector the first
21
+ * time the filter is matched. A configuration file has to be importable on the
22
+ * instance a person is recovering onto, whatever that instance can run.
23
+ */
24
+ export const embedAnchorText: EmbedAnchor = async (sourceText) => {
25
+ try {
26
+ if (!cached) cached = buildEmbeddingServiceFromEnv();
27
+ const [embedding] = await cached.embed([sourceText]);
28
+ return embedding
29
+ ? { embedding, embeddingId: cached.embeddingId }
30
+ : undefined;
31
+ } catch (error) {
32
+ if (!isSemanticCapabilityAbsence(error)) throw error;
33
+ logger.warn(
34
+ { error: error instanceof Error ? error.message : String(error) },
35
+ "No embedding pipeline in this deployment; an imported filter anchor is stored as text and embedded on first use",
36
+ );
37
+ return undefined;
38
+ }
39
+ };
@@ -4,6 +4,7 @@ import type {
4
4
  IAccountRepository,
5
5
  IAccountSettingRepository,
6
6
  IAddressRepository,
7
+ IConfigImportRepository,
7
8
  IEnvelopeRepository,
8
9
  IFilterAnchorRepository,
9
10
  IFilterAnchorTransaction,
@@ -78,6 +79,11 @@ export interface RemitClient {
78
79
  envelope: IEnvelopeRepository;
79
80
  accountExportRequest: IAccountExportRequestRepository;
80
81
 
82
+ // Applied configuration imports (#1021), and the folder references each is
83
+ // still waiting for. Read by GET /config to surface what a file named but
84
+ // IMAP has not produced yet, and written by the binder when it does.
85
+ configImport: IConfigImportRepository;
86
+
81
87
  // Messages the sync path could not read (issue #72). Read-only from the API
82
88
  // process: the sync worker writes the rows, settings lists them.
83
89
  quarantine: IQuarantineRepository;
@@ -163,6 +169,13 @@ export interface RemitClient {
163
169
 
164
170
  // Helper to create IMAP connection scope from accountId
165
171
  createConnectionScope: (accountId: string) => Promise<ConnectionScope>;
172
+
173
+ // Runs an arbitrary set of repository writes as one transaction. Absent on a
174
+ // backend with no cross-entity transaction, where the caller's contract is
175
+ // instead: validate before the first write, fail fast, and report what
176
+ // landed. The message-save write set has its own bound repositories and uses
177
+ // `unitOfWork`; this one takes the repos as they are.
178
+ writeSet?: <T>(run: () => Promise<T>) => Promise<T>;
166
179
  }
167
180
 
168
181
  export interface RemitClientRepositories {
@@ -180,6 +193,7 @@ export interface RemitClientRepositories {
180
193
  threadMessage: IThreadMessageRepository;
181
194
  envelope: IEnvelopeRepository;
182
195
  accountExportRequest: IAccountExportRequestRepository;
196
+ configImport: IConfigImportRepository;
183
197
  quarantine: IQuarantineRepository;
184
198
  organizeJobRequest: IOrganizeJobRequestRepository;
185
199
  placementMove: IMessagePlacementMoveRepository;
@@ -191,6 +205,7 @@ export interface RemitClientRepositories {
191
205
  messageLabel: IMessageLabelRepository;
192
206
  senderSignerStanding: ISenderSignerStandingRepository;
193
207
  unitOfWork?: IUnitOfWork;
208
+ writeSet?: <T>(run: () => Promise<T>) => Promise<T>;
194
209
  }
195
210
 
196
211
  export interface RemitClientSharedDeps {
@@ -407,6 +422,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
407
422
  threadMessage: repositories.threadMessage,
408
423
  envelope: repositories.envelope,
409
424
  accountExportRequest: repositories.accountExportRequest,
425
+ configImport: repositories.configImport,
410
426
  quarantine: repositories.quarantine,
411
427
  organizeJobRequest: repositories.organizeJobRequest,
412
428
  filter: repositories.filter,
@@ -416,6 +432,7 @@ export const createRemitClient = (deps: RemitClientDeps): RemitClient => {
416
432
  messageLabel: repositories.messageLabel,
417
433
  senderSignerStanding: repositories.senderSignerStanding,
418
434
  unitOfWork: repositories.unitOfWork,
435
+ writeSet: repositories.writeSet,
419
436
 
420
437
  storage,
421
438
  search,
@@ -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,8 @@ export type OperationIds =
12
12
  | "MeOperations_getExport"
13
13
  | "MeOperations_listQuarantine"
14
14
  | "ConfigOperations_getConfig"
15
+ | "ConfigOperations_exportConfig"
16
+ | "ConfigOperations_importConfig"
15
17
  | "SystemOperations_getSystemUpdate"
16
18
  | "SystemOperations_applySystemUpdate"
17
19
  | "AccountOperations_createAccount"