@frockbot/connection-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.
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ ConnectionDependencyRouter,
4
+ decodeConnectionCommandIdV1,
5
+ decodeConnectionCommandV1,
6
+ decodeConnectionDependencyCommandV1,
7
+ decodeConnectionDependencyResultV1,
8
+ decodeConnectionModelCatalogV1,
9
+ decodeRevokeConnectionResultV1,
10
+ decodeStartConnectionResultV1,
11
+ type ConnectionDependencyCommandV1,
12
+ } from "./index.js";
13
+
14
+ describe("Connection result contracts", () => {
15
+ test("uses one recoverable command ID contract", () => {
16
+ expect(decodeConnectionCommandIdV1("command-1")).toBe("command-1");
17
+ expect(() =>
18
+ decodeConnectionCommandV1({
19
+ schemaVersion: 1,
20
+ type: "connection/refresh-models",
21
+ commandId: "lost response",
22
+ connectionId: "connection-1",
23
+ }),
24
+ ).toThrow("commandId is invalid");
25
+ expect(() => decodeConnectionCommandIdV1("lost response")).toThrow(
26
+ "commandId is invalid",
27
+ );
28
+ });
29
+
30
+ test("bounds advisory model catalogs", () => {
31
+ const model = {
32
+ providerModelId: "model:cloud",
33
+ displayName: "Model",
34
+ capabilities: { tools: false, vision: false, reasoning: false },
35
+ source: "discovered",
36
+ };
37
+
38
+ expect(() =>
39
+ decodeConnectionModelCatalogV1({
40
+ schemaVersion: 1,
41
+ generation: "catalog-1",
42
+ state: "fresh",
43
+ models: Array.from({ length: 101 }, () => model),
44
+ }),
45
+ ).toThrow("Connection model catalog is invalid");
46
+ });
47
+
48
+ test("requires exact versioned Connection start variants", () => {
49
+ expect(
50
+ decodeStartConnectionResultV1({
51
+ schemaVersion: 1,
52
+ status: "ready",
53
+ connectionId: "gmail-1",
54
+ }),
55
+ ).toEqual({
56
+ schemaVersion: 1,
57
+ status: "ready",
58
+ connectionId: "gmail-1",
59
+ });
60
+ expect(
61
+ decodeStartConnectionResultV1({
62
+ schemaVersion: 1,
63
+ status: "authorization-required",
64
+ connectionId: "gmail-1",
65
+ redirectUrl: "https://connect.example/authorize",
66
+ expiresAt: "2026-08-29T00:05:00.000Z",
67
+ }),
68
+ ).toMatchObject({ status: "authorization-required" });
69
+
70
+ for (const result of [
71
+ { schemaVersion: 1, connectionId: "gmail-1" },
72
+ { schemaVersion: 2, status: "ready", connectionId: "gmail-1" },
73
+ {
74
+ schemaVersion: 1,
75
+ status: "ready",
76
+ connectionId: "gmail-1",
77
+ redirectUrl: "https://connect.example/authorize",
78
+ },
79
+ ]) {
80
+ expect(() => decodeStartConnectionResultV1(result)).toThrow(
81
+ "Connection result is invalid",
82
+ );
83
+ }
84
+ });
85
+
86
+ test("strictly decodes provider-neutral dependency commands and results", () => {
87
+ const claim = {
88
+ schemaVersion: 1,
89
+ action: "claim",
90
+ operationId: "operation-1",
91
+ userId: "user-1",
92
+ packageId: "mail",
93
+ connectionId: "connection-1",
94
+ botId: "bot-1",
95
+ generation: "generation-1",
96
+ requirement: {
97
+ schemaVersion: 1,
98
+ packageId: "mail",
99
+ packageVersion: "1.0.0",
100
+ capabilityId: "send",
101
+ connectionTypeIds: ["oauth"],
102
+ },
103
+ } as const satisfies Extract<
104
+ ConnectionDependencyCommandV1,
105
+ { action: "claim" }
106
+ >;
107
+ expect(decodeConnectionDependencyCommandV1(claim)).toEqual(claim);
108
+ expect(
109
+ decodeConnectionDependencyResultV1({
110
+ schemaVersion: 1,
111
+ status: "pending",
112
+ failure: "provider response is uncertain",
113
+ }),
114
+ ).toMatchObject({ status: "pending" });
115
+ for (const invalid of [
116
+ { ...claim, extra: true },
117
+ { ...claim, requirement: { ...claim.requirement, extra: true } },
118
+ { schemaVersion: 1, status: "claimed", failure: "not allowed" },
119
+ ]) {
120
+ expect(() =>
121
+ "action" in invalid
122
+ ? decodeConnectionDependencyCommandV1(invalid)
123
+ : decodeConnectionDependencyResultV1(invalid),
124
+ ).toThrow();
125
+ }
126
+ });
127
+
128
+ test("routes only to a registered Connection-owning Contribution", async () => {
129
+ const router = new ConnectionDependencyRouter();
130
+ const command = {
131
+ schemaVersion: 1,
132
+ action: "read",
133
+ operationId: "operation-1",
134
+ userId: "user-1",
135
+ packageId: "mail",
136
+ connectionId: "connection-1",
137
+ botId: "bot-1",
138
+ generation: "generation-1",
139
+ } as const;
140
+ await expect(router.execute("missing", command)).resolves.toMatchObject({
141
+ status: "unavailable",
142
+ });
143
+ const calls: unknown[] = [];
144
+ router.register({
145
+ packageId: "mail",
146
+ executeDependency: (input) => {
147
+ calls.push(input);
148
+ return Promise.resolve({ schemaVersion: 1, status: "released" });
149
+ },
150
+ });
151
+ await expect(router.execute("mail", command)).resolves.toMatchObject({
152
+ status: "released",
153
+ });
154
+ expect(calls).toEqual([command]);
155
+ });
156
+
157
+ test("requires an exact versioned revocation result", () => {
158
+ expect(
159
+ decodeRevokeConnectionResultV1({
160
+ schemaVersion: 1,
161
+ status: "revoked",
162
+ }),
163
+ ).toEqual({ schemaVersion: 1, status: "revoked" });
164
+ for (const result of [
165
+ { status: "revoked" },
166
+ { schemaVersion: 2, status: "revoked" },
167
+ { schemaVersion: 1, status: "revoked", extra: true },
168
+ ]) {
169
+ expect(() => decodeRevokeConnectionResultV1(result)).toThrow(
170
+ "revocation result is invalid",
171
+ );
172
+ }
173
+ });
174
+ });
package/src/index.ts ADDED
@@ -0,0 +1,345 @@
1
+ export * from "./authorization-state.js";
2
+ export * from "./credentials.js";
3
+ export * from "./models.js";
4
+
5
+ export type StartConnectionResult =
6
+ | {
7
+ schemaVersion: 1;
8
+ status: "authorization-required";
9
+ connectionId: string;
10
+ redirectUrl: string;
11
+ expiresAt: string;
12
+ nativeReturnNonce?: string;
13
+ }
14
+ | {
15
+ schemaVersion: 1;
16
+ status: "ready";
17
+ connectionId: string;
18
+ nativeReturnNonce?: string;
19
+ };
20
+
21
+ export interface RevokeConnectionResult {
22
+ schemaVersion: 1;
23
+ status: "revoked" | "reconciliation-required";
24
+ }
25
+
26
+ export interface ConnectionCompletionResult {
27
+ returnTarget: "browser" | "desktop";
28
+ status: "ready" | "pending" | "failed";
29
+ nativeReturnNonce?: string;
30
+ }
31
+
32
+ export interface ConnectionDependencyRequirementV1 {
33
+ schemaVersion: 1;
34
+ packageId: string;
35
+ packageVersion: string;
36
+ capabilityId: string;
37
+ connectionTypeIds: string[];
38
+ }
39
+
40
+ export type ConnectionDependencyCommandV1 =
41
+ | {
42
+ schemaVersion: 1;
43
+ action: "claim";
44
+ operationId: string;
45
+ userId: string;
46
+ packageId: string;
47
+ connectionId: string;
48
+ botId: string;
49
+ generation: string;
50
+ requirement: ConnectionDependencyRequirementV1;
51
+ }
52
+ | {
53
+ schemaVersion: 1;
54
+ action: "read" | "acknowledge" | "release" | "reconcile";
55
+ operationId: string;
56
+ userId: string;
57
+ packageId: string;
58
+ connectionId: string;
59
+ botId: string;
60
+ generation: string;
61
+ };
62
+
63
+ export type ConnectionDependencyResultV1 =
64
+ | { schemaVersion: 1; status: "claimed" | "acknowledged" | "released" }
65
+ | {
66
+ schemaVersion: 1;
67
+ status: "pending" | "unavailable" | "absent" | "rejected";
68
+ failure?: string;
69
+ };
70
+
71
+ export interface ConnectionDependencyOwner {
72
+ readonly packageId: string;
73
+ executeDependency(
74
+ command: ConnectionDependencyCommandV1,
75
+ ): Promise<ConnectionDependencyResultV1>;
76
+ }
77
+
78
+ /** Routes dependency commands only to the Contribution owning the Connection Package. */
79
+ export class ConnectionDependencyRouter {
80
+ private readonly owners = new Map<string, ConnectionDependencyOwner>();
81
+
82
+ register(owner: ConnectionDependencyOwner): () => void {
83
+ if (this.owners.has(owner.packageId)) {
84
+ throw new Error(
85
+ `Connection dependency owner "${owner.packageId}" is already registered`,
86
+ );
87
+ }
88
+ this.owners.set(owner.packageId, owner);
89
+ return () => {
90
+ if (this.owners.get(owner.packageId) === owner)
91
+ this.owners.delete(owner.packageId);
92
+ };
93
+ }
94
+
95
+ async execute(
96
+ packageId: string,
97
+ input: unknown,
98
+ ): Promise<ConnectionDependencyResultV1> {
99
+ const command = decodeConnectionDependencyCommandV1(input);
100
+ const owner = this.owners.get(packageId);
101
+ if (!owner) {
102
+ return {
103
+ schemaVersion: 1,
104
+ status: "unavailable",
105
+ failure: `Connection Package "${packageId}" has no backend Contribution`,
106
+ };
107
+ }
108
+ return decodeConnectionDependencyResultV1(
109
+ await owner.executeDependency(command),
110
+ );
111
+ }
112
+ }
113
+
114
+ function record(value: unknown): Record<string, unknown> | undefined {
115
+ return value && typeof value === "object" && !Array.isArray(value)
116
+ ? (value as Record<string, unknown>)
117
+ : undefined;
118
+ }
119
+
120
+ function hasExactKeys(
121
+ value: Record<string, unknown>,
122
+ required: readonly string[],
123
+ optional: readonly string[] = [],
124
+ ): boolean {
125
+ const allowed = new Set<PropertyKey>([...required, ...optional]);
126
+ const keys = Reflect.ownKeys(value);
127
+ return (
128
+ required.every((key) => Object.hasOwn(value, key)) &&
129
+ keys.every((key) => allowed.has(key))
130
+ );
131
+ }
132
+
133
+ function nonemptyString(value: unknown, maximum: number): value is string {
134
+ return (
135
+ typeof value === "string" && value.length > 0 && value.length <= maximum
136
+ );
137
+ }
138
+
139
+ function nativeReturnNonce(value: unknown): value is string | undefined {
140
+ return value === undefined || nonemptyString(value, 128);
141
+ }
142
+
143
+ export function decodeStartConnectionResultV1(
144
+ input: unknown,
145
+ ): StartConnectionResult {
146
+ const value = record(input);
147
+ if (
148
+ !value ||
149
+ value.schemaVersion !== 1 ||
150
+ !nonemptyString(value.connectionId, 128) ||
151
+ !nativeReturnNonce(value.nativeReturnNonce)
152
+ ) {
153
+ throw new Error("Connection result is invalid");
154
+ }
155
+ if (value.status === "ready") {
156
+ if (
157
+ !hasExactKeys(
158
+ value,
159
+ ["schemaVersion", "status", "connectionId"],
160
+ ["nativeReturnNonce"],
161
+ )
162
+ ) {
163
+ throw new Error("Connection result is invalid");
164
+ }
165
+ return {
166
+ schemaVersion: 1,
167
+ status: "ready",
168
+ connectionId: value.connectionId,
169
+ ...(value.nativeReturnNonce === undefined
170
+ ? {}
171
+ : { nativeReturnNonce: value.nativeReturnNonce }),
172
+ };
173
+ }
174
+ if (
175
+ value.status !== "authorization-required" ||
176
+ !hasExactKeys(
177
+ value,
178
+ ["schemaVersion", "status", "connectionId", "redirectUrl", "expiresAt"],
179
+ ["nativeReturnNonce"],
180
+ ) ||
181
+ !nonemptyString(value.redirectUrl, 8_192) ||
182
+ !nonemptyString(value.expiresAt, 64)
183
+ ) {
184
+ throw new Error("Connection result is invalid");
185
+ }
186
+ return {
187
+ schemaVersion: 1,
188
+ status: "authorization-required",
189
+ connectionId: value.connectionId,
190
+ redirectUrl: value.redirectUrl,
191
+ expiresAt: value.expiresAt,
192
+ ...(value.nativeReturnNonce === undefined
193
+ ? {}
194
+ : { nativeReturnNonce: value.nativeReturnNonce }),
195
+ };
196
+ }
197
+
198
+ export function decodeRevokeConnectionResultV1(
199
+ input: unknown,
200
+ ): RevokeConnectionResult {
201
+ const value = record(input);
202
+ if (
203
+ !value ||
204
+ !hasExactKeys(value, ["schemaVersion", "status"]) ||
205
+ value.schemaVersion !== 1 ||
206
+ (value.status !== "revoked" && value.status !== "reconciliation-required")
207
+ ) {
208
+ throw new Error("revocation result is invalid");
209
+ }
210
+ return { schemaVersion: 1, status: value.status };
211
+ }
212
+
213
+ function dependencyIdentity(value: Record<string, unknown>) {
214
+ for (const key of [
215
+ "operationId",
216
+ "userId",
217
+ "packageId",
218
+ "connectionId",
219
+ "botId",
220
+ "generation",
221
+ ] as const) {
222
+ if (!nonemptyString(value[key], 128)) {
223
+ throw new Error(`Connection dependency ${key} is invalid`);
224
+ }
225
+ }
226
+ return {
227
+ operationId: value.operationId as string,
228
+ userId: value.userId as string,
229
+ packageId: value.packageId as string,
230
+ connectionId: value.connectionId as string,
231
+ botId: value.botId as string,
232
+ generation: value.generation as string,
233
+ };
234
+ }
235
+
236
+ function dependencyRequirement(
237
+ input: unknown,
238
+ ): ConnectionDependencyRequirementV1 {
239
+ const value = record(input);
240
+ if (
241
+ !value ||
242
+ !hasExactKeys(value, [
243
+ "schemaVersion",
244
+ "packageId",
245
+ "packageVersion",
246
+ "capabilityId",
247
+ "connectionTypeIds",
248
+ ]) ||
249
+ value.schemaVersion !== 1 ||
250
+ !nonemptyString(value.packageId, 128) ||
251
+ !nonemptyString(value.packageVersion, 100) ||
252
+ !nonemptyString(value.capabilityId, 128) ||
253
+ !Array.isArray(value.connectionTypeIds) ||
254
+ value.connectionTypeIds.length === 0 ||
255
+ value.connectionTypeIds.length > 64 ||
256
+ !value.connectionTypeIds.every((item) => nonemptyString(item, 128))
257
+ ) {
258
+ throw new Error("Connection dependency requirement is invalid");
259
+ }
260
+ return {
261
+ schemaVersion: 1,
262
+ packageId: value.packageId,
263
+ packageVersion: value.packageVersion,
264
+ capabilityId: value.capabilityId,
265
+ connectionTypeIds: [...value.connectionTypeIds],
266
+ };
267
+ }
268
+
269
+ export function decodeConnectionDependencyCommandV1(
270
+ input: unknown,
271
+ ): ConnectionDependencyCommandV1 {
272
+ const value = record(input);
273
+ if (!value || value.schemaVersion !== 1) {
274
+ throw new Error("Connection dependency command is invalid");
275
+ }
276
+ const base = [
277
+ "schemaVersion",
278
+ "action",
279
+ "operationId",
280
+ "userId",
281
+ "packageId",
282
+ "connectionId",
283
+ "botId",
284
+ "generation",
285
+ ];
286
+ if (value.action === "claim") {
287
+ if (!hasExactKeys(value, [...base, "requirement"])) {
288
+ throw new Error("Connection dependency command is invalid");
289
+ }
290
+ return {
291
+ schemaVersion: 1,
292
+ action: "claim",
293
+ ...dependencyIdentity(value),
294
+ requirement: dependencyRequirement(value.requirement),
295
+ };
296
+ }
297
+ if (
298
+ value.action !== "read" &&
299
+ value.action !== "acknowledge" &&
300
+ value.action !== "release" &&
301
+ value.action !== "reconcile"
302
+ ) {
303
+ throw new Error("Connection dependency command is invalid");
304
+ }
305
+ if (!hasExactKeys(value, base)) {
306
+ throw new Error("Connection dependency command is invalid");
307
+ }
308
+ return {
309
+ schemaVersion: 1,
310
+ action: value.action,
311
+ ...dependencyIdentity(value),
312
+ };
313
+ }
314
+
315
+ export function decodeConnectionDependencyResultV1(
316
+ input: unknown,
317
+ ): ConnectionDependencyResultV1 {
318
+ const value = record(input);
319
+ if (
320
+ !value ||
321
+ value.schemaVersion !== 1 ||
322
+ (value.status !== "claimed" &&
323
+ value.status !== "acknowledged" &&
324
+ value.status !== "released" &&
325
+ value.status !== "pending" &&
326
+ value.status !== "unavailable" &&
327
+ value.status !== "absent" &&
328
+ value.status !== "rejected") ||
329
+ !hasExactKeys(value, ["schemaVersion", "status"], ["failure"]) ||
330
+ (value.failure !== undefined && !nonemptyString(value.failure, 512))
331
+ ) {
332
+ throw new Error("Connection dependency result is invalid");
333
+ }
334
+ if (
335
+ (value.status === "claimed" ||
336
+ value.status === "acknowledged" ||
337
+ value.status === "released") &&
338
+ value.failure !== undefined
339
+ ) {
340
+ throw new Error("Connection dependency result is invalid");
341
+ }
342
+ return value.failure === undefined
343
+ ? { schemaVersion: 1, status: value.status }
344
+ : { schemaVersion: 1, status: value.status, failure: value.failure };
345
+ }