@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 +7 -1
- package/scripts/config-save.ts +128 -0
- package/src/handlers/config.ts +89 -1
- package/src/handlers/filter.ts +5 -22
- package/src/service/compose-sqlite.ts +4 -0
- package/src/service/config-import.ts +39 -0
- package/src/service/create-remit-client.ts +17 -0
- package/src/service/export-identity.ts +39 -0
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/backend",
|
|
3
|
-
"version": "0.0.
|
|
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
|
+
});
|
package/src/handlers/config.ts
CHANGED
|
@@ -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
|
};
|
package/src/handlers/filter.ts
CHANGED
|
@@ -3,10 +3,11 @@ import type {
|
|
|
3
3
|
FilterResponse,
|
|
4
4
|
UpdateFilterInput as UpdateFilterRequestBody,
|
|
5
5
|
} from "@remit/api-openapi-types";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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"
|