@frockbot/configuration-core 0.1.3 → 0.1.4

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.
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
3
3
  import {
4
+ decodeInstalledPackageSettingsPatchV1,
5
+ decodeModelBindingV1,
4
6
  decodePackageSettingsPatchV1,
5
7
  decodePackageSettingValuesV1,
6
8
  emptyPackageSettingValuesV1,
@@ -128,6 +130,104 @@ describe("the Package setting values codec", () => {
128
130
  expect(() =>
129
131
  decodePackageSettingsPatchV1(botScoped, { tone: "terse" }),
130
132
  ).toThrow(/not a User-level setting/);
133
+ expect(
134
+ decodePackageSettingsPatchV1(botScoped, { tone: "terse" }, "bot"),
135
+ ).toEqual({ tone: "terse" });
136
+ });
137
+
138
+ test("validates the model role's exact structured value at either scope", () => {
139
+ const modelRole = [
140
+ definition({
141
+ id: "model",
142
+ role: "model",
143
+ scopes: ["user", "bot"],
144
+ schema: {
145
+ type: "object",
146
+ properties: {
147
+ connectionId: { type: "string" },
148
+ providerModelId: { type: "string" },
149
+ },
150
+ required: ["connectionId", "providerModelId"],
151
+ additionalProperties: false,
152
+ },
153
+ }),
154
+ ];
155
+ const model = {
156
+ connectionId: "ollama-work",
157
+ providerModelId: "glm-5.3-flash:cloud",
158
+ };
159
+ expect(decodePackageSettingsPatchV1(modelRole, { model })).toEqual({
160
+ model,
161
+ });
162
+ expect(decodePackageSettingsPatchV1(modelRole, { model }, "bot")).toEqual({
163
+ model,
164
+ });
165
+ expect(() =>
166
+ decodePackageSettingsPatchV1(modelRole, {
167
+ model: { ...model, extra: true },
168
+ }),
169
+ ).toThrow(/invalid fields/);
170
+ });
171
+
172
+ test("rejects hidden, symbol, inherited, and prototype-shaped fields", () => {
173
+ const hidden = { verbose: true };
174
+ Object.defineProperty(hidden, "secret", { value: "hidden" });
175
+ expect(() => decodePackageSettingsPatchV1(declared, hidden)).toThrow(
176
+ /invalid fields/,
177
+ );
178
+
179
+ const symbol = { verbose: true, [Symbol("extra")]: true };
180
+ expect(() => decodePackageSettingsPatchV1(declared, symbol)).toThrow(
181
+ /invalid fields/,
182
+ );
183
+
184
+ const inherited = Object.create({ verbose: true });
185
+ expect(() => decodePackageSettingsPatchV1(declared, inherited)).toThrow(
186
+ /plain object|inherited fields/,
187
+ );
188
+
189
+ expect(() =>
190
+ decodeModelBindingV1({
191
+ connectionId: "constructor",
192
+ providerModelId: "model-1",
193
+ }),
194
+ ).toThrow(/connectionId is invalid/);
195
+ });
196
+
197
+ test("validates a Bot command against the installed manifest version", () => {
198
+ const installations = [{ packageId: "preferences", version: "1.0.0" }];
199
+ const packages = [
200
+ { packageId: "preferences", version: "1.0.0", settings: declared },
201
+ ];
202
+ const validate = (packageId: string, values: unknown) =>
203
+ decodeInstalledPackageSettingsPatchV1({
204
+ packageId,
205
+ values,
206
+ scope: "bot",
207
+ installations,
208
+ packages,
209
+ });
210
+ const botSetting = [
211
+ definition({ id: "tone", scopes: ["bot"], schema: { type: "string" } }),
212
+ ];
213
+ packages[0] = {
214
+ packageId: "preferences",
215
+ version: "1.0.0",
216
+ settings: botSetting,
217
+ };
218
+
219
+ expect(validate("preferences", { tone: "terse" })).toEqual({
220
+ tone: "terse",
221
+ });
222
+ expect(() => validate("missing", { tone: "terse" })).toThrow(
223
+ 'Package "missing" is not installed',
224
+ );
225
+ expect(() => validate("preferences", { unknown: "value" })).toThrow(
226
+ /not declared/,
227
+ );
228
+ expect(() => validate("preferences", { tone: 42 })).toThrow(
229
+ /must be a string/,
230
+ );
131
231
  });
132
232
 
133
233
  test("refuses a secret, whatever its schema says", () => {
@@ -1,5 +1,5 @@
1
1
  // Package-level setting *values*: the durable configuration one installed
2
- // Package carries for one User.
2
+ // Package carries for one User or one of that User's Bots.
3
3
  //
4
4
  // A manifest declares `configuration.settings` — the knobs a Package offers —
5
5
  // and until now nothing anywhere held a value for one, so every Package read
@@ -7,10 +7,10 @@
7
7
  // command and that durable state.
8
8
  //
9
9
  // WHAT BELONGS HERE, AND WHAT DOES NOT. A Package-level setting is
10
- // configuration scoped to the User: a default model, a ceiling, an endpoint
11
- // root. A *secret* never is. A credential reaches the keyring through a
12
- // Connection and only through one, so a definition that declares itself secret
13
- // is refused here with that answer rather than stored.
10
+ // configuration scoped to the User or Bot: a model choice, a ceiling, an
11
+ // endpoint root. A *secret* never is. A credential reaches the keyring through
12
+ // a Connection and only through one, so a definition that declares itself
13
+ // secret is refused here with that answer rather than stored.
14
14
  //
15
15
  // STRICT IN, LENIENT OUT. A write is validated against the exact schema the
16
16
  // Package declared: an unknown setting id, a wrong type, a value outside its
@@ -26,23 +26,30 @@ import type {
26
26
  PackageSettingSchemaValue,
27
27
  } from "@frockbot/kernel-composition";
28
28
  import { ConfigurationDecodeError } from "./errors.js";
29
- import { isPublicIdentifier } from "./identifiers.js";
29
+ import { isConnectionIdentifier, isPublicIdentifier } from "./identifiers.js";
30
30
 
31
31
  /**
32
- * What one Package-level setting may hold. Scalars only: a Package-level value
33
- * is a knob a User turns on a generated form, and an object or an array is a
34
- * document, which belongs in a Package's own durable state rather than in the
35
- * User's settings bag.
32
+ * The provider-neutral model identity selected by a model-role Package
33
+ * setting. This is a reference to a Connection, never a credential.
36
34
  */
37
- export type PackageSettingValueV1 = string | number | boolean;
35
+ export interface ModelBindingV1 {
36
+ connectionId: string;
37
+ providerModelId: string;
38
+ }
38
39
 
39
- /** The durable record of one installed Package's setting values. */
40
+ /**
41
+ * What one Package-level setting may hold. Ordinary settings remain scalars;
42
+ * ADR 0019 adds exactly one structured exception, the model role's binding.
43
+ */
44
+ export type PackageSettingValueV1 = string | number | boolean | ModelBindingV1;
45
+
46
+ /** The durable record of one installed Package's setting values at one scope. */
40
47
  export interface PackageSettingValuesV1 {
41
48
  schemaVersion: 1;
42
49
  values: Record<string, PackageSettingValueV1>;
43
50
  }
44
51
 
45
- /** The durable per-User ceiling on how many values one Package may hold. */
52
+ /** The durable per-scope ceiling on how many values one Package may hold. */
46
53
  export const MAX_PACKAGE_SETTINGS_V1 = 32;
47
54
 
48
55
  /** The ceiling on one text value, whatever the schema's own `maxLength`. */
@@ -57,7 +64,63 @@ function record(value: unknown, label: string): Record<string, unknown> {
57
64
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
58
65
  throw new ConfigurationDecodeError(`${label} must be an object`);
59
66
  }
60
- return value as Record<string, unknown>;
67
+ const candidate = value as Record<string, unknown>;
68
+ const prototype = Object.getPrototypeOf(candidate);
69
+ if (prototype !== Object.prototype && prototype !== null) {
70
+ throw new ConfigurationDecodeError(`${label} must be a plain object`);
71
+ }
72
+ for (const key in candidate) {
73
+ if (!Object.hasOwn(candidate, key)) {
74
+ throw new ConfigurationDecodeError(`${label} has inherited fields`);
75
+ }
76
+ }
77
+ for (const key of Reflect.ownKeys(candidate)) {
78
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
79
+ if (
80
+ typeof key !== "string" ||
81
+ !descriptor ||
82
+ !("value" in descriptor) ||
83
+ !descriptor.enumerable
84
+ ) {
85
+ throw new ConfigurationDecodeError(`${label} has invalid fields`);
86
+ }
87
+ }
88
+ return candidate;
89
+ }
90
+
91
+ function boundedText(value: unknown, label: string, maximum: number): string {
92
+ if (typeof value !== "string") {
93
+ throw new ConfigurationDecodeError(`${label} must be a string`);
94
+ }
95
+ const normalized = value.trim();
96
+ if (!normalized || normalized.length > maximum) {
97
+ throw new ConfigurationDecodeError(`${label} is invalid`);
98
+ }
99
+ return normalized;
100
+ }
101
+
102
+ /** The exact model binding DTO, decoded wherever it crosses a durable seam. */
103
+ export function decodeModelBindingV1(value: unknown): ModelBindingV1 {
104
+ const binding = record(value, "model");
105
+ const keys = Reflect.ownKeys(binding);
106
+ if (
107
+ keys.length !== 2 ||
108
+ !Object.hasOwn(binding, "connectionId") ||
109
+ !Object.hasOwn(binding, "providerModelId")
110
+ ) {
111
+ throw new ConfigurationDecodeError("model has invalid fields");
112
+ }
113
+ if (!isConnectionIdentifier(binding.connectionId)) {
114
+ throw new ConfigurationDecodeError("model.connectionId is invalid");
115
+ }
116
+ return {
117
+ connectionId: binding.connectionId,
118
+ providerModelId: boundedText(
119
+ binding.providerModelId,
120
+ "model.providerModelId",
121
+ 256,
122
+ ),
123
+ };
61
124
  }
62
125
 
63
126
  /**
@@ -169,13 +232,13 @@ function checkNumber(
169
232
  * One value, against the definition its Package declared.
170
233
  *
171
234
  * The scope check is the constitution's, not a convenience: Package
172
- * availability is User-level, so a User-level bag holds a `user`-scoped
173
- * setting. A `connection`-scoped one belongs to a Connection and a `bot`-only
174
- * one to a Bot, and neither has a home here.
235
+ * availability is User-level, while each value still has the manifest scope
236
+ * that owns it. A `connection`-scoped value always belongs to a Connection.
175
237
  */
176
238
  export function decodePackageSettingValueV1(
177
239
  definition: PackageSettingDefinition,
178
240
  value: unknown,
241
+ scope: "user" | "bot" = "user",
179
242
  ): PackageSettingValueV1 {
180
243
  const settingId = definition.id;
181
244
  // Defensive, and deliberately not dead: `PackageSettingDefinition` carries no
@@ -187,13 +250,14 @@ export function decodePackageSettingValueV1(
187
250
  `Package setting "${settingId}" is a secret: use a Connection`,
188
251
  );
189
252
  }
190
- if (!definition.scopes.includes("user")) {
253
+ if (!definition.scopes.includes(scope)) {
191
254
  throw new ConfigurationDecodeError(
192
255
  definition.scopes.includes("connection")
193
256
  ? `Package setting "${settingId}" is Connection-scoped: use a Connection`
194
- : `Package setting "${settingId}" is not a User-level setting`,
257
+ : `Package setting "${settingId}" is not a ${scope === "user" ? "User" : "Bot"}-level setting`,
195
258
  );
196
259
  }
260
+ if (definition.role === "model") return decodeModelBindingV1(value);
197
261
  const schema = definition.schema;
198
262
  const declared = scalarType(schema, settingId);
199
263
  if (
@@ -250,13 +314,14 @@ function definitionsById(
250
314
  }
251
315
 
252
316
  /**
253
- * The `values` a `user/set-package-settings` command carries: a *partial*
254
- * update, so only the ids it names are decoded. Every one of them must be a
255
- * setting the Package declares at User scope.
317
+ * The `values` a User- or Bot-scoped Package settings command carries: a
318
+ * *partial* update, so only the ids it names are decoded. Every one must be a
319
+ * setting the installed Package version declares at the requested scope.
256
320
  */
257
321
  export function decodePackageSettingsPatchV1(
258
322
  definitions: readonly PackageSettingDefinition[],
259
323
  input: unknown,
324
+ scope: "user" | "bot" = "user",
260
325
  ): Record<string, PackageSettingValueV1> {
261
326
  const values = record(input, "values");
262
327
  const entries = Object.entries(values);
@@ -264,7 +329,7 @@ export function decodePackageSettingsPatchV1(
264
329
  throw new ConfigurationDecodeError("Package settings are too many");
265
330
  }
266
331
  const declared = definitionsById(definitions);
267
- const decoded: Record<string, PackageSettingValueV1> = {};
332
+ const decoded: Array<[string, PackageSettingValueV1]> = [];
268
333
  for (const [settingId, value] of entries) {
269
334
  if (!isPublicIdentifier(settingId)) {
270
335
  throw new ConfigurationDecodeError("Package setting id is invalid");
@@ -275,15 +340,129 @@ export function decodePackageSettingsPatchV1(
275
340
  `Package setting "${settingId}" is not declared by this Package`,
276
341
  );
277
342
  }
278
- decoded[settingId] = decodePackageSettingValueV1(definition, value);
343
+ decoded.push([
344
+ settingId,
345
+ decodePackageSettingValueV1(definition, value, scope),
346
+ ]);
347
+ }
348
+ return Object.fromEntries(decoded);
349
+ }
350
+
351
+ /** A non-empty list of declared setting ids to remove at one scope. */
352
+ export function decodePackageSettingIdsV1(
353
+ definitions: readonly PackageSettingDefinition[],
354
+ input: unknown,
355
+ scope: "user" | "bot" = "user",
356
+ ): string[] {
357
+ if (!Array.isArray(input) || input.length === 0) {
358
+ throw new ConfigurationDecodeError("unset names no setting");
359
+ }
360
+ if (input.length > MAX_PACKAGE_SETTINGS_V1) {
361
+ throw new ConfigurationDecodeError("unset is too large");
362
+ }
363
+ const declared = definitionsById(definitions);
364
+ const decoded = input.map((settingId) => {
365
+ if (!isPublicIdentifier(settingId)) {
366
+ throw new ConfigurationDecodeError("Package setting id is invalid");
367
+ }
368
+ const definition = declared.get(settingId);
369
+ if (!definition) {
370
+ throw new ConfigurationDecodeError(
371
+ `Package setting "${settingId}" is not declared by this Package`,
372
+ );
373
+ }
374
+ if (!definition.scopes.includes(scope)) {
375
+ throw new ConfigurationDecodeError(
376
+ `Package setting "${settingId}" is not a ${scope === "user" ? "User" : "Bot"}-level setting`,
377
+ );
378
+ }
379
+ return settingId;
380
+ });
381
+ if (new Set(decoded).size !== decoded.length) {
382
+ throw new ConfigurationDecodeError("unset repeats a setting");
279
383
  }
280
384
  return decoded;
281
385
  }
282
386
 
387
+ export interface InstalledPackageSettingsV1 {
388
+ packageId: string;
389
+ version: string;
390
+ }
391
+
392
+ export interface PackageSettingsDefinitionV1 {
393
+ packageId: string;
394
+ version: string;
395
+ settings: readonly PackageSettingDefinition[];
396
+ }
397
+
398
+ /**
399
+ * The installed-version lookup shared by the User and Bot authorities before
400
+ * either merges a partial settings command. The lookup and schema validation
401
+ * are one fail-closed operation, so neither writer can accidentally validate
402
+ * against a catalog version the User did not install.
403
+ */
404
+ export function decodeInstalledPackageSettingsPatchV1(input: {
405
+ packageId: string;
406
+ values: unknown;
407
+ scope: "user" | "bot";
408
+ installations: readonly InstalledPackageSettingsV1[];
409
+ packages: readonly PackageSettingsDefinitionV1[];
410
+ }): Record<string, PackageSettingValueV1> {
411
+ const installation = input.installations.find(
412
+ (candidate) => candidate.packageId === input.packageId,
413
+ );
414
+ if (!installation) {
415
+ throw new ConfigurationDecodeError(
416
+ `Package "${input.packageId}" is not installed`,
417
+ );
418
+ }
419
+ const pkg = input.packages.find(
420
+ (candidate) =>
421
+ candidate.packageId === installation.packageId &&
422
+ candidate.version === installation.version,
423
+ );
424
+ if (!pkg) {
425
+ throw new ConfigurationDecodeError(
426
+ `Installed Package "${installation.packageId}" version "${installation.version}" has no manifest`,
427
+ );
428
+ }
429
+ return decodePackageSettingsPatchV1(pkg.settings, input.values, input.scope);
430
+ }
431
+
432
+ /** Installed-version lookup and validation for a settings deletion. */
433
+ export function decodeInstalledPackageSettingIdsV1(input: {
434
+ packageId: string;
435
+ unset: unknown;
436
+ scope: "user" | "bot";
437
+ installations: readonly InstalledPackageSettingsV1[];
438
+ packages: readonly PackageSettingsDefinitionV1[];
439
+ }): string[] {
440
+ const installation = input.installations.find(
441
+ (candidate) => candidate.packageId === input.packageId,
442
+ );
443
+ if (!installation) {
444
+ throw new ConfigurationDecodeError(
445
+ `Package "${input.packageId}" is not installed`,
446
+ );
447
+ }
448
+ const pkg = input.packages.find(
449
+ (candidate) =>
450
+ candidate.packageId === installation.packageId &&
451
+ candidate.version === installation.version,
452
+ );
453
+ if (!pkg) {
454
+ throw new ConfigurationDecodeError(
455
+ `Installed Package "${installation.packageId}" version "${installation.version}" has no manifest`,
456
+ );
457
+ }
458
+ return decodePackageSettingIdsV1(pkg.settings, input.unset, input.scope);
459
+ }
460
+
283
461
  /** The durable record, decoded whole. */
284
462
  export function decodePackageSettingValuesV1(
285
463
  definitions: readonly PackageSettingDefinition[],
286
464
  input: unknown,
465
+ scope: "user" | "bot" = "user",
287
466
  ): PackageSettingValuesV1 {
288
467
  const value = record(input, "Package setting values");
289
468
  if (value.schemaVersion !== 1) {
@@ -292,15 +471,15 @@ export function decodePackageSettingValuesV1(
292
471
  );
293
472
  }
294
473
  if (
295
- Object.keys(value).some(
296
- (key) => key !== "schemaVersion" && key !== "values",
297
- )
474
+ Reflect.ownKeys(value).length !== 2 ||
475
+ !Object.hasOwn(value, "schemaVersion") ||
476
+ !Object.hasOwn(value, "values")
298
477
  ) {
299
478
  throw new ConfigurationDecodeError("Package setting values are invalid");
300
479
  }
301
480
  return {
302
481
  schemaVersion: 1,
303
- values: decodePackageSettingsPatchV1(definitions, value.values),
482
+ values: decodePackageSettingsPatchV1(definitions, value.values, scope),
304
483
  };
305
484
  }
306
485
 
@@ -317,6 +496,7 @@ export function decodePackageSettingValuesV1(
317
496
  export function resolvePackageSettingValuesV1(
318
497
  definitions: readonly PackageSettingDefinition[],
319
498
  stored: Readonly<Record<string, unknown>> | undefined,
499
+ scope: "user" | "bot" = "user",
320
500
  ): Record<string, PackageSettingValueV1> {
321
501
  if (!stored) return {};
322
502
  const resolved: Record<string, PackageSettingValueV1> = {};
@@ -326,6 +506,7 @@ export function resolvePackageSettingValuesV1(
326
506
  resolved[definition.id] = decodePackageSettingValueV1(
327
507
  definition,
328
508
  stored[definition.id],
509
+ scope,
329
510
  );
330
511
  } catch {
331
512
  continue;