@remit/config-format 0.0.1

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.
@@ -0,0 +1,21 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { serializeConfigJsonSchema } from "../json-schema.js";
4
+ import { committedSchemaPath } from "../schema-artifact.js";
5
+
6
+ const target = committedSchemaPath();
7
+
8
+ if (!existsSync(target)) {
9
+ console.error(`Config JSON Schema is missing: ${target}`);
10
+ console.error("Run `npm run schema:emit -w @remit/config-format`.");
11
+ process.exit(1);
12
+ }
13
+
14
+ if ((await readFile(target, "utf8")) === serializeConfigJsonSchema()) {
15
+ console.log(`config JSON Schema is current: ${target}`);
16
+ process.exit(0);
17
+ }
18
+
19
+ console.error(`Config JSON Schema is stale: ${target}`);
20
+ console.error("Run `npm run schema:emit -w @remit/config-format`.");
21
+ process.exit(1);
@@ -0,0 +1,12 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { serializeConfigJsonSchema } from "../json-schema.js";
4
+ import { artifactSchemaPath, committedSchemaPath } from "../schema-artifact.js";
5
+
6
+ const serialized = serializeConfigJsonSchema();
7
+
8
+ for (const target of [committedSchemaPath(), artifactSchemaPath()]) {
9
+ await mkdir(dirname(target), { recursive: true });
10
+ await writeFile(target, serialized, "utf8");
11
+ console.log(`wrote ${target}`);
12
+ }
@@ -0,0 +1,216 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ AccountAuthType,
5
+ CanonicalMailboxRole,
6
+ FilterClauseField,
7
+ LabelColor,
8
+ MessageCategory,
9
+ } from "@remit/domain-enums";
10
+ import {
11
+ AccountSchema,
12
+ AddressFlagsEntrySchema,
13
+ FilterSchema,
14
+ FolderOverrideSchema,
15
+ LabelSchema,
16
+ MessageDecisionsSchema,
17
+ ReaderConfigDocumentSchema,
18
+ } from "./document.js";
19
+ import { readGoldenConfigDocument } from "./fixtures.js";
20
+
21
+ const goldenAccount = () =>
22
+ structuredClone(
23
+ (readGoldenConfigDocument() as { accounts: unknown[] }).accounts[0],
24
+ ) as Record<string, unknown>;
25
+
26
+ test("the account schema is strict at every level it defines", () => {
27
+ for (const mutate of [
28
+ (account: Record<string, unknown>) => {
29
+ account.legacyField = 1;
30
+ },
31
+ (account: Record<string, unknown>) => {
32
+ (account.imap as Record<string, unknown>).compress = true;
33
+ },
34
+ (account: Record<string, unknown>) => {
35
+ (account.signature as Record<string, unknown>).markdown = "";
36
+ },
37
+ ]) {
38
+ const account = goldenAccount();
39
+ mutate(account);
40
+
41
+ assert.equal(AccountSchema.safeParse(account).success, false);
42
+ }
43
+ });
44
+
45
+ test("an account declares which credential it needs, and never more", () => {
46
+ const account = goldenAccount();
47
+ account.credentials = { required: "certificate" };
48
+
49
+ assert.equal(AccountSchema.safeParse(account).success, false);
50
+ });
51
+
52
+ test("enum values come from the domain, so a stale spelling is rejected", () => {
53
+ const account = goldenAccount();
54
+ account.authType = "oauthGoogle";
55
+ assert.equal(AccountSchema.safeParse(account).success, false);
56
+
57
+ account.authType = AccountAuthType.OauthMicrosoft;
58
+ assert.equal(AccountSchema.safeParse(account).success, true);
59
+
60
+ assert.equal(
61
+ LabelSchema.safeParse({ name: "x", color: "Chartreuse" }).success,
62
+ false,
63
+ );
64
+ assert.equal(
65
+ LabelSchema.safeParse({ name: "x", color: LabelColor.Teal }).success,
66
+ true,
67
+ );
68
+ });
69
+
70
+ test("folder roles and overrides travel by IMAP path", () => {
71
+ const account = goldenAccount();
72
+ account.folderRoles = [
73
+ { role: CanonicalMailboxRole.Archive, mailboxId: "not-a-path" },
74
+ ];
75
+
76
+ assert.equal(AccountSchema.safeParse(account).success, false);
77
+
78
+ assert.equal(
79
+ FolderOverrideSchema.safeParse({
80
+ folderPath: "INBOX.Lists",
81
+ displayName: "",
82
+ muted: null,
83
+ }).success,
84
+ true,
85
+ );
86
+ });
87
+
88
+ test("a filter names its label by name and its folder by account and path", () => {
89
+ const filter = {
90
+ name: "keep",
91
+ scope: "Standing",
92
+ expiresAt: null,
93
+ matchOperator: "And",
94
+ literalClauses: [{ field: FilterClauseField.ListId, value: "x.example" }],
95
+ actionLabelName: "Facturen",
96
+ actionFolder: {
97
+ accountId: "6f4c2c30-9a2c-4a7f-9f9f-1f2c3d4e5a6b",
98
+ folderPath: "INBOX.Facturen",
99
+ },
100
+ anchor: null,
101
+ };
102
+
103
+ assert.equal(FilterSchema.safeParse(filter).success, true);
104
+ assert.equal(
105
+ FilterSchema.safeParse({ ...filter, actionLabelId: "Facturen" }).success,
106
+ false,
107
+ );
108
+ assert.equal(
109
+ FilterSchema.safeParse({
110
+ ...filter,
111
+ actionFolder: { mailboxId: "6f4c2c30-9a2c-4a7f-9f9f-1f2c3d4e5a6b" },
112
+ }).success,
113
+ false,
114
+ );
115
+ });
116
+
117
+ test("a filter anchor carries its source text and no vector", () => {
118
+ const anchored = {
119
+ name: "like this",
120
+ scope: "Standing",
121
+ expiresAt: null,
122
+ matchOperator: "And",
123
+ literalClauses: [],
124
+ actionLabelName: null,
125
+ actionFolder: null,
126
+ anchor: {
127
+ sourceText: "the release note we file every month",
128
+ embeddingId: "amazon.titan-embed-text-v2:0@1024",
129
+ sourceMessageId: "2c9e4b11-7d5a-4f60-9a31-5b8c0e7f2d44",
130
+ },
131
+ };
132
+
133
+ assert.equal(FilterSchema.safeParse(anchored).success, true);
134
+ assert.equal(
135
+ FilterSchema.safeParse({
136
+ ...anchored,
137
+ anchor: {
138
+ sourceText: "x",
139
+ embeddingId: "m@1",
140
+ sourceMessageId: "2c9e4b11-7d5a-4f60-9a31-5b8c0e7f2d44",
141
+ anchorEmbedding: [0.1, 0.2],
142
+ },
143
+ }).success,
144
+ false,
145
+ );
146
+ });
147
+
148
+ test("an address flag keeps its whole payload", () => {
149
+ const entry = {
150
+ normalizedEmail: "post@bank.example",
151
+ displayName: "Bank",
152
+ flags: {
153
+ blocked: {
154
+ value: true,
155
+ setAt: 1750000000000,
156
+ setBy: "web-client",
157
+ expiresAt: 1790000000000,
158
+ reason: "phishing",
159
+ },
160
+ category: {
161
+ value: MessageCategory.transactional,
162
+ setAt: 1750000000000,
163
+ },
164
+ },
165
+ };
166
+
167
+ const parsed = AddressFlagsEntrySchema.parse(entry);
168
+
169
+ assert.deepEqual(parsed, entry);
170
+ assert.equal(
171
+ AddressFlagsEntrySchema.safeParse({
172
+ ...entry,
173
+ flags: { blocked: true },
174
+ }).success,
175
+ false,
176
+ );
177
+ });
178
+
179
+ test("an unknown flag key is refused, so a flag is added deliberately", () => {
180
+ assert.equal(
181
+ AddressFlagsEntrySchema.safeParse({
182
+ normalizedEmail: "a@b.example",
183
+ displayName: "",
184
+ flags: { shouted: { value: true, setAt: 1 } },
185
+ }).success,
186
+ false,
187
+ );
188
+ });
189
+
190
+ test("the two derived flags have no place in the file", () => {
191
+ for (const flag of ["wellknown", "junkOnly"]) {
192
+ assert.equal(
193
+ AddressFlagsEntrySchema.safeParse({
194
+ normalizedEmail: "a@b.example",
195
+ displayName: "",
196
+ flags: { [flag]: { value: true, setAt: 1 } },
197
+ }).success,
198
+ false,
199
+ );
200
+ }
201
+ });
202
+
203
+ test("the reserved decisions slot is defined and empty", () => {
204
+ assert.equal(MessageDecisionsSchema.safeParse([]).success, true);
205
+ assert.equal(MessageDecisionsSchema.safeParse([{}]).success, false);
206
+ });
207
+
208
+ test("the document schema refuses an unknown top-level section", () => {
209
+ const source = readGoldenConfigDocument() as Record<string, unknown>;
210
+
211
+ assert.equal(
212
+ ReaderConfigDocumentSchema.safeParse({ ...source, savedSearches: [] })
213
+ .success,
214
+ false,
215
+ );
216
+ });
@@ -0,0 +1,262 @@
1
+ import {
2
+ AccountAuthType,
3
+ CanonicalMailboxRole,
4
+ FilterClauseField,
5
+ FilterMatchOperator,
6
+ FilterScope,
7
+ LabelColor,
8
+ MessageCategory,
9
+ } from "@remit/domain-enums";
10
+ import { z } from "zod/v4";
11
+ import { CONFIG_KIND, CURRENT_SCHEMA_VERSION } from "./version.js";
12
+
13
+ const NonEmpty = z.string().min(1);
14
+ const Uuid = z.uuid();
15
+ const IsoDateTime = z.iso.datetime({ offset: true });
16
+ const Port = z.int().min(1).max(65535);
17
+ const FlagTimestamp = z.int();
18
+
19
+ /**
20
+ * An IMAP path, as the server spells it. Folders travel by path rather than by
21
+ * id: the ids are local to the instance that wrote the file, the path is what
22
+ * the receiving instance can actually resolve against its own mailbox list.
23
+ */
24
+ const ImapPath = z.string().min(1).max(512);
25
+
26
+ /**
27
+ * Which reader wrote the file, and when. A reader that does not recognise the
28
+ * generator still reads the document: the envelope already said it could.
29
+ */
30
+ export const GeneratorSchema = z
31
+ .strictObject({
32
+ app: NonEmpty,
33
+ version: NonEmpty,
34
+ exportedAt: IsoDateTime,
35
+ })
36
+ .describe("Which reader wrote this file, and when.");
37
+
38
+ /**
39
+ * Where the file came from, recorded and never authoritative. A database drop
40
+ * can change the account config id, so the importing instance rebinds to its
41
+ * own; this block only lets a person tell two exports apart.
42
+ */
43
+ export const ProvenanceSchema = z
44
+ .strictObject({
45
+ accountConfigId: Uuid,
46
+ instance: z.string(),
47
+ })
48
+ .describe("The instance this file was exported from. Never applied.");
49
+
50
+ /** The audit metadata every sender-level flag and mute carries. */
51
+ export const FlagMetadataSchema = z.strictObject({
52
+ value: z.boolean(),
53
+ setAt: FlagTimestamp,
54
+ setBy: z.string().optional(),
55
+ expiresAt: FlagTimestamp.optional(),
56
+ reason: z.string().optional(),
57
+ });
58
+
59
+ export const MutedFlagSchema = FlagMetadataSchema;
60
+
61
+ const CategoryFlagSchema = z.strictObject({
62
+ value: z.enum(MessageCategory),
63
+ setAt: FlagTimestamp,
64
+ setBy: z.string().optional(),
65
+ expiresAt: FlagTimestamp.optional(),
66
+ reason: z.string().optional(),
67
+ });
68
+
69
+ /**
70
+ * Sender-level flags, sparse exactly as they are stored: a key's presence is
71
+ * the flag being set. Each entry carries its full payload — who set it, when,
72
+ * why, and when it lapses — because a flag stripped to a boolean arrives on the
73
+ * other side as an assertion nobody can date or explain.
74
+ *
75
+ * `wellknown` and `junkOnly` have no place here. The machine derives both, from
76
+ * engagement counters and from where mail was seen, and both come back on their
77
+ * own after a resync; carrying them would let a stale file overrule what the
78
+ * receiving instance has since worked out for itself.
79
+ */
80
+ export const AddressFlagsSchema = z.strictObject({
81
+ trusted: FlagMetadataSchema.optional(),
82
+ blocked: FlagMetadataSchema.optional(),
83
+ muted: FlagMetadataSchema.optional(),
84
+ vip: FlagMetadataSchema.optional(),
85
+ category: CategoryFlagSchema.optional(),
86
+ autoArchive: FlagMetadataSchema.optional(),
87
+ unsubscribed: FlagMetadataSchema.optional(),
88
+ });
89
+
90
+ export const AddressFlagsEntrySchema = z.strictObject({
91
+ /** Lowercased address. The identity an importing instance matches on. */
92
+ normalizedEmail: NonEmpty,
93
+ /**
94
+ * The personal name last seen on mail from this address. A display hint for
95
+ * an import preview and nothing more: it is not authoritative, it is never
96
+ * matched on, and an importing instance overwrites it from its own mail.
97
+ */
98
+ displayName: z.string(),
99
+ flags: AddressFlagsSchema,
100
+ });
101
+
102
+ /**
103
+ * Which credential the account needs to come back to life, never the credential
104
+ * itself. The importing instance prompts for what this names.
105
+ */
106
+ export const CredentialRequirementSchema = z.strictObject({
107
+ required: z.enum(["password", "oauth"]),
108
+ provider: z.literal("microsoft").optional(),
109
+ });
110
+
111
+ export const ImapEndpointSchema = z.strictObject({
112
+ host: NonEmpty,
113
+ port: Port,
114
+ tls: z.boolean(),
115
+ startTls: z.boolean(),
116
+ });
117
+
118
+ export const SmtpEndpointSchema = z.strictObject({
119
+ enabled: z.boolean(),
120
+ host: z.string(),
121
+ port: Port,
122
+ tls: z.boolean(),
123
+ startTls: z.boolean(),
124
+ /** Empty when sending reuses the IMAP username. */
125
+ username: z.string(),
126
+ });
127
+
128
+ export const SignatureSchema = z.strictObject({
129
+ plainText: z.string(),
130
+ html: z.string(),
131
+ });
132
+
133
+ /** A canonical role, bound to the folder that fills it by IMAP path. */
134
+ export const FolderRoleSchema = z.strictObject({
135
+ role: z.enum(CanonicalMailboxRole),
136
+ folderPath: ImapPath,
137
+ });
138
+
139
+ /** Per-folder settings that survive the move, keyed by IMAP path. */
140
+ export const FolderOverrideSchema = z.strictObject({
141
+ folderPath: ImapPath,
142
+ /** Empty when the folder keeps the name the server gives it. */
143
+ displayName: z.string(),
144
+ muted: MutedFlagSchema.nullable(),
145
+ });
146
+
147
+ export const AccountSchema = z.strictObject({
148
+ accountId: Uuid,
149
+ email: NonEmpty,
150
+ username: NonEmpty,
151
+ authType: z.enum(AccountAuthType),
152
+ credentials: CredentialRequirementSchema,
153
+ isActive: z.boolean(),
154
+ imap: ImapEndpointSchema,
155
+ smtp: SmtpEndpointSchema,
156
+ /** Empty when the client derives a label from the address. */
157
+ displayName: z.string(),
158
+ muted: MutedFlagSchema.nullable(),
159
+ composeLanguages: z.array(NonEmpty),
160
+ signature: SignatureSchema,
161
+ folderRoles: z.array(FolderRoleSchema),
162
+ folderOverrides: z.array(FolderOverrideSchema),
163
+ pinnedFolders: z.array(ImapPath),
164
+ });
165
+
166
+ export const AccountConfigSchema = z.strictObject({
167
+ name: z.string(),
168
+ });
169
+
170
+ export const LabelSchema = z.strictObject({
171
+ name: z.string().min(1).max(140),
172
+ color: z.enum(LabelColor),
173
+ });
174
+
175
+ export const FilterClauseSchema = z.strictObject({
176
+ field: z.enum(FilterClauseField),
177
+ value: z.string().min(1).max(256),
178
+ });
179
+
180
+ /**
181
+ * A semantic anchor as text, never as numbers. The vector is a function of the
182
+ * embedding model that produced it, so it means nothing on an instance running
183
+ * a different one; the source text re-embeds against whatever model the
184
+ * importing instance actually has.
185
+ */
186
+ export const FilterAnchorSchema = z.strictObject({
187
+ sourceText: z.string().min(1).max(512),
188
+ /** `<modelId>@<dimensions>` the vector was built with, for provenance. */
189
+ embeddingId: NonEmpty,
190
+ sourceMessageId: Uuid,
191
+ });
192
+
193
+ /** A folder named the only way it can be named across instances. */
194
+ export const FolderRefSchema = z.strictObject({
195
+ accountId: Uuid,
196
+ folderPath: ImapPath,
197
+ });
198
+
199
+ export const FilterSchema = z.strictObject({
200
+ name: z.string().min(1).max(256),
201
+ scope: z.enum(FilterScope),
202
+ /** ISO 8601 with offset when the scope is Temporary, otherwise null. */
203
+ expiresAt: IsoDateTime.nullable(),
204
+ matchOperator: z.enum(FilterMatchOperator),
205
+ literalClauses: z.array(FilterClauseSchema),
206
+ /** The label this filter applies, by name — ids are local to an instance. */
207
+ actionLabelName: z.string().min(1).max(140).nullable(),
208
+ actionFolder: FolderRefSchema.nullable(),
209
+ anchor: FilterAnchorSchema.nullable(),
210
+ });
211
+
212
+ export const SavedSearchSchema = z.strictObject({
213
+ name: NonEmpty,
214
+ query: z.string(),
215
+ });
216
+
217
+ /**
218
+ * Preferences the browser holds, not the server. They survive a database drop
219
+ * already; they ride along so they also survive a change of browser. The server
220
+ * ignores this block.
221
+ */
222
+ export const ClientPreferencesSchema = z.strictObject({
223
+ theme: z.string(),
224
+ density: z.string(),
225
+ defaultComposerFormat: z.string(),
226
+ savedSearches: z.array(SavedSearchSchema),
227
+ });
228
+
229
+ /**
230
+ * Reserved. Per-message decisions — what was filed where, what was dismissed —
231
+ * are the one part of a mailbox's history this format does not yet describe,
232
+ * and the slot is named here so a later version can fill it without moving
233
+ * anything else. Nothing writes it and nothing reads it, so v1 requires it to
234
+ * be empty: accepting entries a reader would then drop is worse than refusing
235
+ * the file.
236
+ */
237
+ export const MessageDecisionsSchema = z
238
+ .array(z.unknown())
239
+ .max(0)
240
+ .describe("Reserved for a later schema version; must be empty in v1.");
241
+
242
+ export const ReaderConfigDocumentSchema = z.strictObject({
243
+ kind: z.literal(CONFIG_KIND),
244
+ schemaVersion: z.literal(CURRENT_SCHEMA_VERSION),
245
+ generator: GeneratorSchema,
246
+ provenance: ProvenanceSchema,
247
+ accountConfig: AccountConfigSchema,
248
+ accounts: z.array(AccountSchema),
249
+ labels: z.array(LabelSchema),
250
+ filters: z.array(FilterSchema),
251
+ addressFlags: z.array(AddressFlagsEntrySchema),
252
+ clientPreferences: ClientPreferencesSchema.optional(),
253
+ messageDecisions: MessageDecisionsSchema.optional(),
254
+ });
255
+
256
+ export type ReaderConfigDocument = z.infer<typeof ReaderConfigDocumentSchema>;
257
+ export type ConfigAccount = z.infer<typeof AccountSchema>;
258
+ export type ConfigFilter = z.infer<typeof FilterSchema>;
259
+ export type ConfigLabel = z.infer<typeof LabelSchema>;
260
+ export type ConfigAddressFlagsEntry = z.infer<typeof AddressFlagsEntrySchema>;
261
+ export type ConfigAddressFlags = z.infer<typeof AddressFlagsSchema>;
262
+ export type ConfigClientPreferences = z.infer<typeof ClientPreferencesSchema>;
@@ -0,0 +1,15 @@
1
+ import { z } from "zod/v4";
2
+ import { CONFIG_KIND } from "./version.js";
3
+
4
+ /**
5
+ * The first of the two passes. Deliberately tiny and deliberately not strict:
6
+ * it answers "what is this file, and which version of the format does it claim
7
+ * to be" without any opinion about the rest, so a document written by a newer
8
+ * reader still identifies itself instead of failing as a wall of unknown keys.
9
+ */
10
+ export const ConfigEnvelopeSchema = z.object({
11
+ kind: z.literal(CONFIG_KIND),
12
+ schemaVersion: z.int().min(1),
13
+ });
14
+
15
+ export type ConfigEnvelope = z.infer<typeof ConfigEnvelopeSchema>;
package/src/errors.ts ADDED
@@ -0,0 +1,119 @@
1
+ import type { $ZodIssue } from "zod/v4/core";
2
+
3
+ /**
4
+ * Every way reading a configuration document can fail, as a closed set. A
5
+ * caller renders the failure from `code` rather than from an `instanceof`
6
+ * ladder; the classes exist so the throw carries the offending detail with it.
7
+ */
8
+ export const ConfigReadErrorCode = {
9
+ NotAnObject: "NotAnObject",
10
+ WrongKind: "WrongKind",
11
+ UnsupportedVersion: "UnsupportedVersion",
12
+ UnknownKeys: "UnknownKeys",
13
+ CredentialPresent: "CredentialPresent",
14
+ Malformed: "Malformed",
15
+ } as const;
16
+
17
+ export type ConfigReadErrorCodeValue =
18
+ (typeof ConfigReadErrorCode)[keyof typeof ConfigReadErrorCode];
19
+
20
+ export abstract class ConfigReadError extends Error {
21
+ abstract readonly code: ConfigReadErrorCodeValue;
22
+
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = new.target.name;
26
+ }
27
+ }
28
+
29
+ export class ConfigNotAnObjectError extends ConfigReadError {
30
+ readonly code = ConfigReadErrorCode.NotAnObject;
31
+ }
32
+
33
+ export class ConfigKindError extends ConfigReadError {
34
+ readonly code = ConfigReadErrorCode.WrongKind;
35
+ readonly expected: string;
36
+ readonly received: unknown;
37
+
38
+ constructor(expected: string, received: unknown) {
39
+ super(
40
+ `Not a reader configuration document: expected kind "${expected}", found ${JSON.stringify(received) ?? "nothing"}.`,
41
+ );
42
+ this.expected = expected;
43
+ this.received = received;
44
+ }
45
+ }
46
+
47
+ export class ConfigVersionError extends ConfigReadError {
48
+ readonly code = ConfigReadErrorCode.UnsupportedVersion;
49
+ readonly documentVersion: number;
50
+ readonly supportedVersion: number;
51
+
52
+ constructor(documentVersion: number, supportedVersion: number) {
53
+ super(
54
+ documentVersion > supportedVersion
55
+ ? `Document schemaVersion ${documentVersion} was written by a newer reader; this one reads up to ${supportedVersion}. Upgrade before importing.`
56
+ : `Document schemaVersion ${documentVersion} has no migration to ${supportedVersion}.`,
57
+ );
58
+ this.documentVersion = documentVersion;
59
+ this.supportedVersion = supportedVersion;
60
+ }
61
+ }
62
+
63
+ export class ConfigUnknownKeysError extends ConfigReadError {
64
+ readonly code = ConfigReadErrorCode.UnknownKeys;
65
+ readonly keys: readonly string[];
66
+ readonly path: string;
67
+
68
+ constructor(path: string, keys: readonly string[]) {
69
+ super(
70
+ `Unknown ${keys.length === 1 ? "key" : "keys"} at ${path}: ${keys.join(", ")}. A document this reader does not fully understand is refused rather than partly applied.`,
71
+ );
72
+ this.keys = keys;
73
+ this.path = path;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A configuration document is a description of accounts, never a copy of their
79
+ * secrets. A file carrying one is refused outright: importing it would move a
80
+ * credential through a channel that was never meant to hold one, and silently
81
+ * dropping the field would teach the exporter that writing it is harmless.
82
+ */
83
+ export class ConfigCredentialError extends ConfigReadError {
84
+ readonly code = ConfigReadErrorCode.CredentialPresent;
85
+ readonly keys: readonly string[];
86
+ readonly path: string;
87
+
88
+ constructor(path: string, keys: readonly string[]) {
89
+ super(
90
+ `Credential ${keys.length === 1 ? "field" : "fields"} at ${path}: ${keys.join(", ")}. A configuration document declares which credential an account needs; it never carries one.`,
91
+ );
92
+ this.keys = keys;
93
+ this.path = path;
94
+ }
95
+ }
96
+
97
+ export class ConfigMalformedError extends ConfigReadError {
98
+ readonly code = ConfigReadErrorCode.Malformed;
99
+ readonly issues: readonly $ZodIssue[];
100
+
101
+ constructor(issues: readonly $ZodIssue[]) {
102
+ super(
103
+ `Configuration document does not match schema version: ${issues
104
+ .map((issue) => `${formatPath(issue.path)}: ${issue.message}`)
105
+ .join("; ")}`,
106
+ );
107
+ this.issues = issues;
108
+ }
109
+ }
110
+
111
+ export function formatPath(path: readonly PropertyKey[]): string {
112
+ if (path.length === 0) return "(document root)";
113
+ return path
114
+ .map((segment) =>
115
+ typeof segment === "number" ? `[${segment}]` : `.${String(segment)}`,
116
+ )
117
+ .join("")
118
+ .replace(/^\./, "");
119
+ }