@frockbot/configuration-core 0.0.0 → 0.1.0

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,14 +1,30 @@
1
1
  {
2
2
  "name": "@frockbot/configuration-core",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./bot-id": "./src/bot-id.ts"
9
+ },
10
+ "scripts": {
11
+ "test": "bun test src",
12
+ "typecheck": "tsc --noEmit -p tsconfig.json"
13
+ },
14
+ "dependencies": {
15
+ "@frockbot/connection-core": "0.1.0",
16
+ "@frockbot/kernel-composition": "0.1.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "1.3.6",
20
+ "typescript": "^7.0.2"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
6
25
  "repository": {
7
26
  "type": "git",
8
27
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
28
  "directory": "packages/configuration-core"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
29
  }
14
30
  }
package/src/bot-id.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { isPublicIdentifier } from "./identifiers.js";
2
+
3
+ export function isBotIdV1(value: unknown): value is string {
4
+ return isPublicIdentifier(value);
5
+ }
@@ -0,0 +1,186 @@
1
+ // Bot identity: the title, the name's provenance, and the sidebar-hidden flag.
2
+ //
3
+ // The first test is the one that matters most: a Bot settings record written
4
+ // before any of this existed must still decode, because widening a durable DTO
5
+ // with optional fields is the only migration this project permits.
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ applyBotProfilePatchV1,
9
+ decodeBotSettingsViewV1,
10
+ decodeConfigurationCommandV1,
11
+ type BotProfile,
12
+ } from "./index.js";
13
+
14
+ function settings(profile: Record<string, unknown>): Record<string, unknown> {
15
+ return {
16
+ schemaVersion: 1,
17
+ botId: "primary",
18
+ revision: 2,
19
+ profile,
20
+ notifications: { enabled: false },
21
+ assignments: [],
22
+ assignmentOperations: [],
23
+ };
24
+ }
25
+
26
+ describe("Bot identity codecs", () => {
27
+ test("decodes a Bot settings record written before identity was widened", () => {
28
+ const decoded = decodeBotSettingsViewV1(
29
+ settings({ name: "Housework", description: "Keeps things tidy." }),
30
+ );
31
+ expect(decoded.profile).toEqual({
32
+ name: "Housework",
33
+ description: "Keeps things tidy.",
34
+ });
35
+ expect(decoded.profile.title).toBeUndefined();
36
+ expect(decoded.profile.namedBy).toBeUndefined();
37
+ expect(decoded.profile.hiddenFromSidebar).toBeUndefined();
38
+ });
39
+
40
+ test("round-trips a title, provenance, and the hidden flag", () => {
41
+ const profile = {
42
+ name: "Housework",
43
+ title: "Chief of staff",
44
+ namedBy: "bot" as const,
45
+ hiddenFromSidebar: true,
46
+ };
47
+ expect(decodeBotSettingsViewV1(settings(profile)).profile).toEqual(profile);
48
+ });
49
+
50
+ test("refuses a profile field outside the declared vocabulary", () => {
51
+ expect(() =>
52
+ decodeBotSettingsViewV1(settings({ name: "Housework", nickname: "H" })),
53
+ ).toThrow("profile has invalid fields");
54
+ expect(() =>
55
+ decodeBotSettingsViewV1(
56
+ settings({ name: "Housework", namedBy: "admin" }),
57
+ ),
58
+ ).toThrow("profile.namedBy is invalid");
59
+ expect(() =>
60
+ decodeBotSettingsViewV1(
61
+ settings({ name: "Housework", hiddenFromSidebar: "yes" }),
62
+ ),
63
+ ).toThrow("profile.hiddenFromSidebar must be a boolean");
64
+ });
65
+ });
66
+
67
+ describe("bot/set-profile", () => {
68
+ test("decodes a partial update carrying only the fields it changes", () => {
69
+ expect(
70
+ decodeConfigurationCommandV1({
71
+ schemaVersion: 1,
72
+ type: "bot/set-profile",
73
+ commandId: "command-1",
74
+ botId: "primary",
75
+ expectedRevision: 3,
76
+ namedBy: "bot",
77
+ profile: { name: "Atlas" },
78
+ }),
79
+ ).toEqual({
80
+ schemaVersion: 1,
81
+ type: "bot/set-profile",
82
+ commandId: "command-1",
83
+ botId: "primary",
84
+ expectedRevision: 3,
85
+ namedBy: "bot",
86
+ profile: { name: "Atlas" },
87
+ });
88
+ });
89
+
90
+ test("carries the Bot writer, and refuses one aimed at another Bot", () => {
91
+ const command = {
92
+ schemaVersion: 1,
93
+ type: "bot/set-profile",
94
+ commandId: "command-1",
95
+ botId: "primary",
96
+ expectedRevision: 3,
97
+ namedBy: "bot",
98
+ profile: { name: "Atlas" },
99
+ } as const;
100
+ const writer = {
101
+ kind: "bot",
102
+ botId: "primary",
103
+ sessionId: "user-1:primary",
104
+ turnId: "turn-4",
105
+ } as const;
106
+ expect(decodeConfigurationCommandV1({ ...command, writer })).toEqual({
107
+ ...command,
108
+ writer,
109
+ });
110
+ // A Bot writes only its own profile, so the writer must name the target.
111
+ expect(() =>
112
+ decodeConfigurationCommandV1({
113
+ ...command,
114
+ writer: { ...writer, botId: "other" },
115
+ }),
116
+ ).toThrow("writer.botId is invalid");
117
+ expect(() =>
118
+ decodeConfigurationCommandV1({
119
+ ...command,
120
+ writer: { ...writer, kind: "user" },
121
+ }),
122
+ ).toThrow("writer.kind is invalid");
123
+ expect(() =>
124
+ decodeConfigurationCommandV1({
125
+ ...command,
126
+ writer: { ...writer, runId: "run-1" },
127
+ }),
128
+ ).toThrow("writer has invalid fields");
129
+ });
130
+
131
+ test("refuses an empty patch and an unknown patch field", () => {
132
+ const command = {
133
+ schemaVersion: 1,
134
+ type: "bot/set-profile",
135
+ commandId: "command-1",
136
+ botId: "primary",
137
+ expectedRevision: 3,
138
+ };
139
+ expect(() =>
140
+ decodeConfigurationCommandV1({ ...command, profile: {} }),
141
+ ).toThrow("profile has invalid fields");
142
+ expect(() =>
143
+ decodeConfigurationCommandV1({ ...command, profile: { namedBy: "bot" } }),
144
+ ).toThrow("profile has invalid fields");
145
+ });
146
+
147
+ test("changes only the fields the patch carries", () => {
148
+ const current: BotProfile = {
149
+ name: "Housework",
150
+ title: "Chief of staff",
151
+ description: "Keeps things tidy.",
152
+ hiddenFromSidebar: true,
153
+ };
154
+ expect(
155
+ applyBotProfilePatchV1(current, { title: "Night shift" }, "user"),
156
+ ).toEqual({ ...current, title: "Night shift" });
157
+ });
158
+
159
+ test("records the writer only when the name actually changes", () => {
160
+ const current: BotProfile = { name: "Housework", namedBy: "user" };
161
+ expect(
162
+ applyBotProfilePatchV1(current, { name: "Atlas" }, "bot").namedBy,
163
+ ).toBe("bot");
164
+ expect(
165
+ applyBotProfilePatchV1(current, { name: "Housework" }, "bot").namedBy,
166
+ ).toBe("user");
167
+ expect(
168
+ applyBotProfilePatchV1(current, { title: "Chief" }, "bot").namedBy,
169
+ ).toBe("user");
170
+ });
171
+
172
+ test("clears optional fields with empty values", () => {
173
+ const current: BotProfile = {
174
+ name: "Housework",
175
+ title: "Chief of staff",
176
+ hiddenFromSidebar: true,
177
+ };
178
+ expect(
179
+ applyBotProfilePatchV1(
180
+ current,
181
+ { title: "", hiddenFromSidebar: false },
182
+ "user",
183
+ ),
184
+ ).toEqual({ name: "Housework" });
185
+ });
186
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The refusal every configuration seam raises when an inbound value is not
3
+ * what its contract declares.
4
+ *
5
+ * It lives in its own module so a decoder that is *not* the main configuration
6
+ * codec — `./package-settings.ts`, which validates a value against the schema
7
+ * its Package declared — can raise the same refusal without importing the
8
+ * module that re-exports it.
9
+ *
10
+ * The name is load-bearing: it crosses the Durable Object RPC boundary, where
11
+ * the class does not, and the gateway maps it to 400 by name.
12
+ */
13
+ export class ConfigurationDecodeError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = "ConfigurationDecodeError";
17
+ }
18
+ }
@@ -0,0 +1,40 @@
1
+ const PUBLIC_IDENTIFIER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
2
+ const RPC_IDENTIFIER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:@-]{0,127}$/;
3
+ const APPLICATION_DEPLOYMENT_HASH_PATTERN =
4
+ /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
5
+
6
+ const RESERVED_CONNECTION_IDENTIFIERS = new Set([
7
+ "__defineGetter__",
8
+ "__defineSetter__",
9
+ "__lookupGetter__",
10
+ "__lookupSetter__",
11
+ "__proto__",
12
+ "constructor",
13
+ "hasOwnProperty",
14
+ "isPrototypeOf",
15
+ "propertyIsEnumerable",
16
+ "prototype",
17
+ "toLocaleString",
18
+ "toString",
19
+ "valueOf",
20
+ ]);
21
+
22
+ export function isPublicIdentifier(value: unknown): value is string {
23
+ return typeof value === "string" && PUBLIC_IDENTIFIER_PATTERN.test(value);
24
+ }
25
+
26
+ export function isRpcIdentifier(value: unknown): value is string {
27
+ return typeof value === "string" && RPC_IDENTIFIER_PATTERN.test(value);
28
+ }
29
+
30
+ export function isApplicationDeploymentHash(value: unknown): value is string {
31
+ return (
32
+ typeof value === "string" && APPLICATION_DEPLOYMENT_HASH_PATTERN.test(value)
33
+ );
34
+ }
35
+
36
+ export function isConnectionIdentifier(value: unknown): value is string {
37
+ return (
38
+ isPublicIdentifier(value) && !RESERVED_CONNECTION_IDENTIFIERS.has(value)
39
+ );
40
+ }