@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,169 @@
1
+ {
2
+ "kind": "reader.config",
3
+ "schemaVersion": 1,
4
+ "generator": {
5
+ "app": "reader",
6
+ "version": "0.1.0",
7
+ "exportedAt": "2026-08-27T09:15:00+02:00"
8
+ },
9
+ "provenance": {
10
+ "accountConfigId": "0d9c8b7a-6e5f-4d3c-b2a1-0f9e8d7c6b5a",
11
+ "instance": "reader.ischen.nl"
12
+ },
13
+ "accountConfig": {
14
+ "name": "Matthijs"
15
+ },
16
+ "accounts": [
17
+ {
18
+ "accountId": "6f4c2c30-9a2c-4a7f-9f9f-1f2c3d4e5a6b",
19
+ "email": "matthijs@ischen.nl",
20
+ "username": "matthijs@ischen.nl",
21
+ "authType": "password",
22
+ "credentials": { "required": "password" },
23
+ "isActive": true,
24
+ "imap": {
25
+ "host": "imap.ischen.nl",
26
+ "port": 993,
27
+ "tls": true,
28
+ "startTls": false
29
+ },
30
+ "smtp": {
31
+ "enabled": true,
32
+ "host": "smtp.ischen.nl",
33
+ "port": 587,
34
+ "tls": false,
35
+ "startTls": true,
36
+ "username": ""
37
+ },
38
+ "displayName": "Matthijs van Henten",
39
+ "muted": null,
40
+ "composeLanguages": ["nl-NL", "en-GB"],
41
+ "signature": {
42
+ "plainText": "Matthijs van Henten\n",
43
+ "html": "<p>Matthijs van Henten</p>"
44
+ },
45
+ "folderRoles": [
46
+ { "role": "Inbox", "folderPath": "INBOX" },
47
+ { "role": "Sent", "folderPath": "INBOX.Sent" },
48
+ { "role": "Junk", "folderPath": "INBOX.Junk" },
49
+ { "role": "Trash", "folderPath": "INBOX.Trash" }
50
+ ],
51
+ "folderOverrides": [
52
+ {
53
+ "folderPath": "INBOX.Lists.dev-null",
54
+ "displayName": "dev/null",
55
+ "muted": {
56
+ "value": true,
57
+ "setAt": 1756281600000,
58
+ "setBy": "web-client",
59
+ "reason": "high volume, read on demand"
60
+ }
61
+ }
62
+ ],
63
+ "pinnedFolders": ["INBOX", "INBOX.Facturen"]
64
+ },
65
+ {
66
+ "accountId": "b3a1d0c7-5e64-4f1b-8c2a-77e9d3b40f18",
67
+ "email": "work@example.com",
68
+ "username": "work@example.com",
69
+ "authType": "oauthMicrosoft",
70
+ "credentials": { "required": "oauth", "provider": "microsoft" },
71
+ "isActive": true,
72
+ "imap": {
73
+ "host": "outlook.office365.com",
74
+ "port": 993,
75
+ "tls": true,
76
+ "startTls": false
77
+ },
78
+ "smtp": {
79
+ "enabled": false,
80
+ "host": "",
81
+ "port": 587,
82
+ "tls": false,
83
+ "startTls": true,
84
+ "username": ""
85
+ },
86
+ "displayName": "",
87
+ "muted": null,
88
+ "composeLanguages": [],
89
+ "signature": { "plainText": "", "html": "" },
90
+ "folderRoles": [{ "role": "Inbox", "folderPath": "INBOX" }],
91
+ "folderOverrides": [],
92
+ "pinnedFolders": []
93
+ }
94
+ ],
95
+ "labels": [
96
+ { "name": "Facturen", "color": "Blue" },
97
+ { "name": "Te lezen", "color": "Default" }
98
+ ],
99
+ "filters": [
100
+ {
101
+ "name": "Invoices to Facturen",
102
+ "scope": "Standing",
103
+ "expiresAt": null,
104
+ "matchOperator": "Or",
105
+ "literalClauses": [
106
+ { "field": "Subject", "value": "factuur" },
107
+ { "field": "FromDomain", "value": "moneybird.com" }
108
+ ],
109
+ "actionLabelName": "Facturen",
110
+ "actionFolder": {
111
+ "accountId": "6f4c2c30-9a2c-4a7f-9f9f-1f2c3d4e5a6b",
112
+ "folderPath": "INBOX.Facturen"
113
+ },
114
+ "anchor": null
115
+ },
116
+ {
117
+ "name": "Anything like this release note",
118
+ "scope": "Temporary",
119
+ "expiresAt": "2026-12-31T23:59:59+01:00",
120
+ "matchOperator": "And",
121
+ "literalClauses": [],
122
+ "actionLabelName": "Te lezen",
123
+ "actionFolder": null,
124
+ "anchor": {
125
+ "sourceText": "We have shipped version 4.2 of the runtime; the changelog covers the new scheduler.",
126
+ "embeddingId": "amazon.titan-embed-text-v2:0@1024",
127
+ "sourceMessageId": "2c9e4b11-7d5a-4f60-9a31-5b8c0e7f2d44"
128
+ }
129
+ }
130
+ ],
131
+ "addressFlags": [
132
+ {
133
+ "normalizedEmail": "post@bank.example",
134
+ "displayName": "Bank",
135
+ "flags": {
136
+ "vip": {
137
+ "value": true,
138
+ "setAt": 1750000000000,
139
+ "setBy": "web-client",
140
+ "reason": "payment alerts"
141
+ },
142
+ "category": {
143
+ "value": "transactional",
144
+ "setAt": 1750000000000
145
+ }
146
+ }
147
+ },
148
+ {
149
+ "normalizedEmail": "noreply@newsletter.example",
150
+ "displayName": "Weekly Roundup",
151
+ "flags": {
152
+ "unsubscribed": {
153
+ "value": true,
154
+ "setAt": 1755000000000,
155
+ "expiresAt": 1790000000000,
156
+ "reason": "unsubscribe link never worked"
157
+ },
158
+ "muted": { "value": true, "setAt": 1755000000000 }
159
+ }
160
+ }
161
+ ],
162
+ "clientPreferences": {
163
+ "theme": "dark",
164
+ "density": "comfortable",
165
+ "defaultComposerFormat": "markdown",
166
+ "savedSearches": [{ "name": "Unpaid invoices", "query": "label:Facturen is:unread" }]
167
+ },
168
+ "messageDecisions": []
169
+ }
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * The committed v1 document. It is the seed the migration chain regresses
6
+ * against: every later version has to lift this exact file, so the shape v1
7
+ * shipped with can never be quietly redefined.
8
+ */
9
+ export const goldenConfigDocumentPath = (): string =>
10
+ fileURLToPath(new URL("./fixtures/reader-config.v1.json", import.meta.url));
11
+
12
+ export const readGoldenConfigDocument = (): unknown =>
13
+ JSON.parse(readFileSync(goldenConfigDocumentPath(), "utf8"));
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { readGoldenConfigDocument } from "./fixtures.js";
4
+ import { readConfigDocument } from "./read.js";
5
+ import { CURRENT_SCHEMA_VERSION } from "./version.js";
6
+
7
+ test("the golden v1 document parses", () => {
8
+ const document = readConfigDocument(readGoldenConfigDocument());
9
+
10
+ assert.equal(document.schemaVersion, CURRENT_SCHEMA_VERSION);
11
+ assert.equal(document.accounts.length, 2);
12
+ assert.deepEqual(
13
+ document.accounts.map((account) => account.credentials.required),
14
+ ["password", "oauth"],
15
+ );
16
+ });
17
+
18
+ test("the golden v1 document round-trips through JSON unchanged", () => {
19
+ const source = readGoldenConfigDocument();
20
+
21
+ const parsed = readConfigDocument(source);
22
+
23
+ assert.deepEqual(JSON.parse(JSON.stringify(parsed)), source);
24
+ });
25
+
26
+ test("the golden v1 document carries the parts a move has to preserve", () => {
27
+ const document = readConfigDocument(readGoldenConfigDocument());
28
+ const [primary] = document.accounts;
29
+ assert.ok(primary);
30
+
31
+ assert.deepEqual(
32
+ primary.folderRoles.find((role) => role.role === "Sent"),
33
+ { role: "Sent", folderPath: "INBOX.Sent" },
34
+ );
35
+ assert.deepEqual(primary.pinnedFolders, ["INBOX", "INBOX.Facturen"]);
36
+ assert.equal(
37
+ primary.folderOverrides[0]?.muted?.reason,
38
+ "high volume, read on demand",
39
+ );
40
+ assert.equal(document.filters[0]?.actionFolder?.folderPath, "INBOX.Facturen");
41
+ assert.equal(document.filters[0]?.actionLabelName, "Facturen");
42
+ assert.ok(document.filters[1]?.anchor?.sourceText);
43
+ assert.equal(document.addressFlags[0]?.flags.vip?.reason, "payment alerts");
44
+ assert.equal(
45
+ document.addressFlags[1]?.flags.unsubscribed?.expiresAt,
46
+ 1790000000000,
47
+ );
48
+ });
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import * as configFormat from "./index.js";
4
+
5
+ test("the package exports the reader, the schemas and the named failures", () => {
6
+ for (const name of [
7
+ "CONFIG_KIND",
8
+ "CURRENT_SCHEMA_VERSION",
9
+ "ConfigEnvelopeSchema",
10
+ "ReaderConfigDocumentSchema",
11
+ "readConfigDocument",
12
+ "liftToCurrentVersion",
13
+ "configMigrations",
14
+ "buildConfigJsonSchema",
15
+ "readGoldenConfigDocument",
16
+ "ConfigKindError",
17
+ "ConfigVersionError",
18
+ "ConfigUnknownKeysError",
19
+ "ConfigCredentialError",
20
+ "ConfigMalformedError",
21
+ "ConfigNotAnObjectError",
22
+ ]) {
23
+ assert.ok(name in configFormat, `missing export: ${name}`);
24
+ }
25
+ });
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./document.js";
2
+ export * from "./envelope.js";
3
+ export * from "./errors.js";
4
+ export * from "./fixtures.js";
5
+ export * from "./json-schema.js";
6
+ export * from "./migrations.js";
7
+ export * from "./read.js";
8
+ export * from "./schema-artifact.js";
9
+ export * from "./version.js";
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { test } from "node:test";
4
+ import {
5
+ buildConfigJsonSchema,
6
+ serializeConfigJsonSchema,
7
+ } from "./json-schema.js";
8
+ import { artifactSchemaPath, committedSchemaPath } from "./schema-artifact.js";
9
+ import { CONFIG_SCHEMA_ARTIFACT_PATH, CONFIG_SCHEMA_ID } from "./version.js";
10
+
11
+ type JsonObject = Record<string, unknown>;
12
+
13
+ const schema = () => buildConfigJsonSchema() as JsonObject;
14
+
15
+ test("the emitted schema identifies itself", () => {
16
+ const emitted = schema();
17
+
18
+ assert.equal(emitted.$id, CONFIG_SCHEMA_ID);
19
+ assert.equal(emitted.$schema, "https://json-schema.org/draft/2020-12/schema");
20
+ });
21
+
22
+ test("the emitted schema pins the envelope and closes the document", () => {
23
+ const emitted = schema();
24
+ const properties = emitted.properties as JsonObject;
25
+
26
+ assert.deepEqual((properties.kind as JsonObject).const, "reader.config");
27
+ assert.deepEqual((properties.schemaVersion as JsonObject).const, 1);
28
+ assert.equal(emitted.additionalProperties, false);
29
+ assert.deepEqual(emitted.required, [
30
+ "kind",
31
+ "schemaVersion",
32
+ "generator",
33
+ "provenance",
34
+ "accountConfig",
35
+ "accounts",
36
+ "labels",
37
+ "filters",
38
+ "addressFlags",
39
+ ]);
40
+ });
41
+
42
+ test("the emitted schema closes every account object too", () => {
43
+ const properties = schema().properties as JsonObject;
44
+ const account = (properties.accounts as JsonObject).items as JsonObject;
45
+
46
+ assert.equal(account.additionalProperties, false);
47
+ assert.equal(
48
+ Object.hasOwn(account.properties as JsonObject, "credentials"),
49
+ true,
50
+ );
51
+ assert.equal(
52
+ Object.hasOwn(account.properties as JsonObject, "password"),
53
+ false,
54
+ );
55
+ });
56
+
57
+ test("the generated artifact lands at its declared path under the repository root", () => {
58
+ assert.ok(artifactSchemaPath().endsWith(`/${CONFIG_SCHEMA_ARTIFACT_PATH}`));
59
+ });
60
+
61
+ test("the committed schema artifact matches what the code emits", () => {
62
+ assert.equal(
63
+ readFileSync(committedSchemaPath(), "utf8"),
64
+ serializeConfigJsonSchema(),
65
+ );
66
+ });
@@ -0,0 +1,24 @@
1
+ import { z } from "zod/v4";
2
+ import { ReaderConfigDocumentSchema } from "./document.js";
3
+ import { CONFIG_SCHEMA_ID, CURRENT_SCHEMA_VERSION } from "./version.js";
4
+
5
+ /**
6
+ * The document schema as JSON Schema, for anyone writing a reader
7
+ * configuration file without this package: an editor, a script, another client.
8
+ * Derived from the zod schema rather than maintained beside it, so the two
9
+ * cannot describe different documents.
10
+ */
11
+ export function buildConfigJsonSchema(): Record<string, unknown> {
12
+ return {
13
+ ...z.toJSONSchema(ReaderConfigDocumentSchema, {
14
+ target: "draft-2020-12",
15
+ io: "input",
16
+ }),
17
+ $id: CONFIG_SCHEMA_ID,
18
+ title: `reader configuration document, v${CURRENT_SCHEMA_VERSION}`,
19
+ };
20
+ }
21
+
22
+ export function serializeConfigJsonSchema(): string {
23
+ return `${JSON.stringify(buildConfigJsonSchema(), null, "\t")}\n`;
24
+ }
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { ConfigVersionError } from "./errors.js";
4
+ import { readGoldenConfigDocument } from "./fixtures.js";
5
+ import {
6
+ type ConfigMigration,
7
+ configMigrations,
8
+ liftToCurrentVersion,
9
+ } from "./migrations.js";
10
+ import { readConfigDocument } from "./read.js";
11
+ import { CURRENT_SCHEMA_VERSION } from "./version.js";
12
+
13
+ test("v1 needs no migrations, and the chain is empty rather than absent", () => {
14
+ assert.deepEqual(configMigrations, []);
15
+ });
16
+
17
+ test("a document already at the current version passes through untouched", () => {
18
+ const source = readGoldenConfigDocument() as Record<string, unknown>;
19
+
20
+ assert.equal(liftToCurrentVersion(source, CURRENT_SCHEMA_VERSION), source);
21
+ });
22
+
23
+ test("the chain lifts one version at a time", () => {
24
+ const migrations: ConfigMigration[] = [
25
+ {
26
+ from: 1,
27
+ to: 2,
28
+ migrate: (document) => ({ ...document, schemaVersion: 2, added: "two" }),
29
+ },
30
+ {
31
+ from: 2,
32
+ to: 3,
33
+ migrate: (document) => ({
34
+ ...document,
35
+ schemaVersion: 3,
36
+ added: "three",
37
+ }),
38
+ },
39
+ ];
40
+
41
+ const lifted = liftToCurrentVersion({ schemaVersion: 1 }, 1, migrations, 3);
42
+
43
+ assert.deepEqual(lifted, { schemaVersion: 3, added: "three" });
44
+ });
45
+
46
+ test("a gap in the chain fails instead of leaving the document at its old version", () => {
47
+ const migrations: ConfigMigration[] = [
48
+ { from: 2, to: 3, migrate: (document) => document },
49
+ ];
50
+
51
+ assert.throws(
52
+ () => liftToCurrentVersion({ schemaVersion: 1 }, 1, migrations, 3),
53
+ (error: unknown) => {
54
+ assert.ok(error instanceof ConfigVersionError);
55
+ assert.equal(error.documentVersion, 1);
56
+ assert.equal(error.supportedVersion, 3);
57
+ return true;
58
+ },
59
+ );
60
+ });
61
+
62
+ test("a document already at the current version never enters the chain", () => {
63
+ let ran = 0;
64
+ const migrations: ConfigMigration[] = [
65
+ {
66
+ from: 1,
67
+ to: 2,
68
+ migrate: (document) => {
69
+ ran += 1;
70
+ return document;
71
+ },
72
+ },
73
+ ];
74
+
75
+ const parsed = readConfigDocument(readGoldenConfigDocument(), migrations);
76
+
77
+ assert.equal(ran, 0);
78
+ assert.equal(parsed.schemaVersion, CURRENT_SCHEMA_VERSION);
79
+ });
@@ -0,0 +1,45 @@
1
+ import { ConfigVersionError } from "./errors.js";
2
+ import { CURRENT_SCHEMA_VERSION } from "./version.js";
3
+
4
+ export type ConfigDocumentShape = Record<string, unknown>;
5
+
6
+ /**
7
+ * One step of the chain that lifts an older document to the current version.
8
+ * A migration works on the raw object, before the strict parse: the shape it
9
+ * receives satisfied an older schema, and the shape it returns is only claimed
10
+ * to satisfy the next one.
11
+ */
12
+ export type ConfigMigration = {
13
+ readonly from: number;
14
+ readonly to: number;
15
+ migrate(document: ConfigDocumentShape): ConfigDocumentShape;
16
+ };
17
+
18
+ /**
19
+ * Empty at v1, and the golden fixture is the seed that keeps it honest: the
20
+ * first migration added here has a committed v1 document to lift, so the chain
21
+ * is exercised by the same test that proves v1 still parses.
22
+ */
23
+ export const configMigrations: readonly ConfigMigration[] = [];
24
+
25
+ /**
26
+ * Apply the chain from `fromVersion` up to the current version. A gap in the
27
+ * chain is a fault, not a skipped step — silently leaving a document at an old
28
+ * version would hand the strict parse a shape it was never meant to see.
29
+ */
30
+ export function liftToCurrentVersion(
31
+ document: ConfigDocumentShape,
32
+ fromVersion: number,
33
+ migrations: readonly ConfigMigration[] = configMigrations,
34
+ targetVersion: number = CURRENT_SCHEMA_VERSION,
35
+ ): ConfigDocumentShape {
36
+ let version = fromVersion;
37
+ let lifted = document;
38
+ while (version < targetVersion) {
39
+ const step = migrations.find((migration) => migration.from === version);
40
+ if (!step) throw new ConfigVersionError(fromVersion, targetVersion);
41
+ lifted = step.migrate(lifted);
42
+ version = step.to;
43
+ }
44
+ return lifted;
45
+ }
@@ -0,0 +1,198 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ ConfigCredentialError,
5
+ ConfigKindError,
6
+ ConfigMalformedError,
7
+ ConfigNotAnObjectError,
8
+ ConfigReadErrorCode,
9
+ ConfigUnknownKeysError,
10
+ ConfigVersionError,
11
+ } from "./errors.js";
12
+ import { readGoldenConfigDocument } from "./fixtures.js";
13
+ import { readConfigDocument } from "./read.js";
14
+
15
+ type Mutable = Record<string, unknown>;
16
+ type MutableAccount = Record<string, unknown>;
17
+
18
+ const document = (): Mutable =>
19
+ structuredClone(readGoldenConfigDocument()) as Mutable;
20
+
21
+ const firstAccount = (source: Mutable): MutableAccount =>
22
+ (source.accounts as MutableAccount[])[0] as MutableAccount;
23
+
24
+ test("a value that is not a JSON object is refused before anything else", () => {
25
+ for (const input of ["{}", 1, null, [], undefined]) {
26
+ assert.throws(() => readConfigDocument(input), ConfigNotAnObjectError);
27
+ }
28
+ });
29
+
30
+ test("a foreign document is named by kind, not by its unknown keys", () => {
31
+ const source = { ...document(), kind: "some.other.format" };
32
+
33
+ assert.throws(
34
+ () => readConfigDocument(source),
35
+ (error: unknown) => {
36
+ assert.ok(error instanceof ConfigKindError);
37
+ assert.equal(error.code, ConfigReadErrorCode.WrongKind);
38
+ assert.equal(error.expected, "reader.config");
39
+ assert.equal(error.received, "some.other.format");
40
+ return true;
41
+ },
42
+ );
43
+ });
44
+
45
+ test("a document with no kind at all is refused as the wrong kind", () => {
46
+ const source = document();
47
+ delete source.kind;
48
+
49
+ assert.throws(() => readConfigDocument(source), ConfigKindError);
50
+ });
51
+
52
+ test("a newer document is refused by version, before its shape is judged", () => {
53
+ const source = { ...document(), schemaVersion: 2, somethingV2Added: true };
54
+
55
+ assert.throws(
56
+ () => readConfigDocument(source),
57
+ (error: unknown) => {
58
+ assert.ok(error instanceof ConfigVersionError);
59
+ assert.equal(error.documentVersion, 2);
60
+ assert.equal(error.supportedVersion, 1);
61
+ return true;
62
+ },
63
+ );
64
+ });
65
+
66
+ test("a version that is not a positive integer is malformed, not unsupported", () => {
67
+ for (const schemaVersion of [0, -1, 1.5, "1", null]) {
68
+ assert.throws(
69
+ () => readConfigDocument({ ...document(), schemaVersion }),
70
+ ConfigMalformedError,
71
+ );
72
+ }
73
+ });
74
+
75
+ test("an unknown top-level key is refused rather than dropped", () => {
76
+ const source = { ...document(), notes: "hand-edited" };
77
+
78
+ assert.throws(
79
+ () => readConfigDocument(source),
80
+ (error: unknown) => {
81
+ assert.ok(error instanceof ConfigUnknownKeysError);
82
+ assert.equal(error.path, "(document root)");
83
+ assert.deepEqual(error.keys, ["notes"]);
84
+ return true;
85
+ },
86
+ );
87
+ });
88
+
89
+ test("an unknown key names the nested object it sits in", () => {
90
+ const source = document();
91
+ firstAccount(source).imapDebug = true;
92
+
93
+ assert.throws(
94
+ () => readConfigDocument(source),
95
+ (error: unknown) => {
96
+ assert.ok(error instanceof ConfigUnknownKeysError);
97
+ assert.equal(error.path, "accounts[0]");
98
+ return true;
99
+ },
100
+ );
101
+ });
102
+
103
+ test("an account carrying a password is refused as a credential, not as an unknown key", () => {
104
+ const source = document();
105
+ firstAccount(source).password = "hunter2";
106
+
107
+ assert.throws(
108
+ () => readConfigDocument(source),
109
+ (error: unknown) => {
110
+ assert.ok(error instanceof ConfigCredentialError);
111
+ assert.equal(error.code, ConfigReadErrorCode.CredentialPresent);
112
+ assert.equal(error.path, "accounts[0]");
113
+ assert.deepEqual(error.keys, ["password"]);
114
+ return true;
115
+ },
116
+ );
117
+ });
118
+
119
+ test("a blank credential is refused exactly like a filled one", () => {
120
+ const source = document();
121
+ firstAccount(source).password = "";
122
+
123
+ assert.throws(() => readConfigDocument(source), ConfigCredentialError);
124
+ });
125
+
126
+ test("every spelling of a credential is refused", () => {
127
+ for (const key of [
128
+ "passwordHash",
129
+ "smtpPassword",
130
+ "smtpPasswordHash",
131
+ "oauthRefreshToken",
132
+ "oauthRefreshTokenHash",
133
+ "accessToken",
134
+ "clientSecret",
135
+ "apiKey",
136
+ ]) {
137
+ const source = document();
138
+ firstAccount(source)[key] = "value";
139
+
140
+ assert.throws(
141
+ () => readConfigDocument(source),
142
+ (error: unknown) => {
143
+ assert.ok(error instanceof ConfigCredentialError);
144
+ assert.deepEqual(error.keys, [key]);
145
+ return true;
146
+ },
147
+ );
148
+ }
149
+ });
150
+
151
+ test("a credential smuggled into the requirement declaration is refused", () => {
152
+ const source = document();
153
+ firstAccount(source).credentials = {
154
+ required: "password",
155
+ password: "hunter2",
156
+ };
157
+
158
+ assert.throws(
159
+ () => readConfigDocument(source),
160
+ (error: unknown) => {
161
+ assert.ok(error instanceof ConfigCredentialError);
162
+ assert.equal(error.path, "accounts[0].credentials");
163
+ return true;
164
+ },
165
+ );
166
+ });
167
+
168
+ test("a declared credential requirement is what a valid document carries instead", () => {
169
+ const parsed = readConfigDocument(document());
170
+
171
+ assert.deepEqual(parsed.accounts[0]?.credentials, { required: "password" });
172
+ assert.equal(Object.hasOwn(parsed.accounts[0] as object, "password"), false);
173
+ });
174
+
175
+ test("a body that fails the schema for any other reason is malformed", () => {
176
+ const source = document();
177
+ source.accounts = "none";
178
+
179
+ assert.throws(() => readConfigDocument(source), ConfigMalformedError);
180
+ });
181
+
182
+ test("the reserved decisions slot accepts nothing in v1", () => {
183
+ const source = document();
184
+ source.messageDecisions = [{ messageId: "x", decision: "filed" }];
185
+
186
+ assert.throws(() => readConfigDocument(source), ConfigMalformedError);
187
+ });
188
+
189
+ test("the optional slots may be omitted entirely", () => {
190
+ const source = document();
191
+ delete source.clientPreferences;
192
+ delete source.messageDecisions;
193
+
194
+ const parsed = readConfigDocument(source);
195
+
196
+ assert.equal(parsed.clientPreferences, undefined);
197
+ assert.equal(parsed.messageDecisions, undefined);
198
+ });