@zackbart/connecta 0.24.2 → 0.24.3
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/CHANGELOG.md +141 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/downstream-oauth.d.ts +12 -1
- package/dist/auth/downstream-oauth.js +147 -35
- package/dist/call-admission.d.ts +4 -0
- package/dist/call-admission.js +26 -0
- package/dist/catalog-drift.js +9 -4
- package/dist/catalog-service.d.ts +2 -0
- package/dist/catalog-service.js +25 -8
- package/dist/catalog.d.ts +2 -0
- package/dist/catalog.js +246 -121
- package/dist/connectors/api.js +11 -1
- package/dist/connectors/guarded-fetch.d.ts +1 -1
- package/dist/connectors/guarded-fetch.js +27 -20
- package/dist/connectors/remote-mcp.js +84 -53
- package/dist/errors.d.ts +17 -0
- package/dist/errors.js +58 -0
- package/dist/execute.js +85 -23
- package/dist/executor-result.js +3 -1
- package/dist/executors/quickjs-child.js +5 -1
- package/dist/executors/quickjs-protocol.d.ts +4 -0
- package/dist/executors/quickjs-runtime.d.ts +1 -1
- package/dist/executors/quickjs-runtime.js +38 -21
- package/dist/executors/quickjs.js +68 -27
- package/dist/index.d.ts +14 -0
- package/dist/index.js +24 -3
- package/dist/invocation.js +134 -93
- package/dist/mcp-result.js +3 -2
- package/dist/meta-tools.js +118 -39
- package/dist/registry.d.ts +14 -2
- package/dist/registry.js +87 -13
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +84 -13
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +1 -0
- package/dist/routes/shared.js +4 -4
- package/dist/server.js +15 -3
- package/dist/skills.js +6 -5
- package/dist/storage/file.d.ts +6 -2
- package/dist/storage/file.js +312 -34
- package/dist/storage/memory.js +12 -1
- package/dist/validate.js +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +22 -6
- package/documentation/auth.md +42 -9
- package/documentation/call-admission.md +24 -8
- package/documentation/code-mode.md +34 -22
- package/documentation/connectors.md +47 -5
- package/documentation/meta-tools.md +74 -6
- package/documentation/operations.md +19 -19
- package/documentation/provider-conventions.md +7 -0
- package/documentation/request-admission.md +38 -4
- package/documentation/storage-and-credentials.md +54 -1
- package/documentation/upgrading.md +18 -4
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
package/dist/storage/file.js
CHANGED
|
@@ -1,11 +1,263 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { chmodSync, closeSync, existsSync, futimesSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
const HEARTBEAT_MS = 15_000;
|
|
6
|
+
const STALE_LOCK_MS = 60_000;
|
|
7
|
+
const localLocks = new Map();
|
|
8
|
+
// A pid is meaningful only in its own host/namespace. Container hostnames may
|
|
9
|
+
// be shared, so Linux uses the kernel boot id and the actual PID namespace.
|
|
10
|
+
const pidScope = (() => {
|
|
11
|
+
if (process.platform !== "linux")
|
|
12
|
+
return hostname();
|
|
13
|
+
try {
|
|
14
|
+
return `${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${readlinkSync("/proc/self/ns/pid")}`;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined; // Without namespace evidence, rely on the heartbeat.
|
|
18
|
+
}
|
|
19
|
+
})();
|
|
20
|
+
function stale(path) {
|
|
21
|
+
return Date.now() - statSync(path).mtimeMs > STALE_LOCK_MS;
|
|
22
|
+
}
|
|
23
|
+
// One listener per module, removed when the last store closes. Node's listen()
|
|
24
|
+
// drains requests before process.exit(), so the lock lasts through those writes.
|
|
25
|
+
const openStores = new Set();
|
|
26
|
+
const closeStores = () => {
|
|
27
|
+
for (const close of openStores) {
|
|
28
|
+
try {
|
|
29
|
+
close();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A dead-pid or expired-heartbeat lock can be reclaimed next startup.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
function hasCode(error, code) {
|
|
37
|
+
return error?.code === code;
|
|
38
|
+
}
|
|
39
|
+
function lockFile(path) {
|
|
40
|
+
const lockPath = `${path}.lock`;
|
|
41
|
+
const contents = JSON.stringify({
|
|
42
|
+
pid: process.pid,
|
|
43
|
+
pidScope,
|
|
44
|
+
createdAt: Date.now(),
|
|
45
|
+
id: randomUUID(),
|
|
46
|
+
});
|
|
47
|
+
const readLock = () => {
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync(lockPath, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (hasCode(error, "ENOENT"))
|
|
53
|
+
return null;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const refuseLiveHolder = () => {
|
|
58
|
+
const raw = readLock();
|
|
59
|
+
if (raw === null)
|
|
60
|
+
return false;
|
|
61
|
+
try {
|
|
62
|
+
if (stale(lockPath))
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (hasCode(error, "ENOENT"))
|
|
67
|
+
return false;
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
let holder;
|
|
71
|
+
try {
|
|
72
|
+
holder = JSON.parse(raw);
|
|
73
|
+
if (!holder || !Number.isInteger(holder.pid) || holder.pid <= 0 ||
|
|
74
|
+
holder.pid > 2147483647 || !Number.isFinite(holder.createdAt)) {
|
|
75
|
+
throw new Error("Invalid lock holder");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error(`[connecta] state file ${path} has an unreadable lock at ${lockPath}. ` +
|
|
80
|
+
`Refusing to start. Retry after its heartbeat has been stale for 60 seconds.`);
|
|
81
|
+
}
|
|
82
|
+
if (pidScope !== undefined && holder.pidScope === pidScope) {
|
|
83
|
+
if (holder.pid === process.pid) {
|
|
84
|
+
// A new container process may inherit the old holder's pid. Only our
|
|
85
|
+
// own registry can distinguish that incarnation from a second opener.
|
|
86
|
+
if (localLocks.get(path) !== raw)
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(holder.pid, 0);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (hasCode(error, "ESRCH"))
|
|
95
|
+
return true;
|
|
96
|
+
// Permission errors do not establish that the holder is dead.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`[connecta] state file ${path} is held by pid ${holder.pid} ` +
|
|
101
|
+
`(lock timestamp ${holder.createdAt}). Close that store before opening another.`);
|
|
102
|
+
};
|
|
103
|
+
const acquire = () => {
|
|
104
|
+
let fd;
|
|
105
|
+
try {
|
|
106
|
+
fd = openSync(lockPath, "wx", 0o600);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (hasCode(error, "EEXIST"))
|
|
110
|
+
return false;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(fd, contents);
|
|
115
|
+
const now = new Date();
|
|
116
|
+
futimesSync(fd, now, now);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
unlinkSync(lockPath);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
closeSync(fd);
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
if (!acquire()) {
|
|
128
|
+
refuseLiveHolder();
|
|
129
|
+
// Serialize stale-lock removal. Without this guard, two reclaimers could
|
|
130
|
+
// both observe the dead pid and the slower one unlink the new live lock.
|
|
131
|
+
// Recovery is synchronous. A guard older than the lease is from a crashed
|
|
132
|
+
// or paused reclaimer, which must recheck its ownership before continuing.
|
|
133
|
+
const reclaimPath = `${lockPath}.reclaim`;
|
|
134
|
+
const recoveryError = () => new Error(`[connecta] state file ${path} has a lock recovery in progress at ${reclaimPath}. ` +
|
|
135
|
+
`Retry after its 60-second lease expires.`);
|
|
136
|
+
try {
|
|
137
|
+
const expired = statSync(reclaimPath);
|
|
138
|
+
if (Date.now() - expired.mtimeMs > STALE_LOCK_MS) {
|
|
139
|
+
const markers = readdirSync(reclaimPath);
|
|
140
|
+
const current = statSync(reclaimPath);
|
|
141
|
+
if (current.dev !== expired.dev || current.ino !== expired.ino ||
|
|
142
|
+
current.birthtimeMs !== expired.birthtimeMs || !stale(reclaimPath)) {
|
|
143
|
+
throw recoveryError();
|
|
144
|
+
}
|
|
145
|
+
// Delete only the expired owner's unique marker. A competing cleanup
|
|
146
|
+
// cannot empty a replacement guard by deleting these old filenames.
|
|
147
|
+
for (const marker of markers)
|
|
148
|
+
rmSync(`${reclaimPath}/${marker}`, { force: true });
|
|
149
|
+
rmdirSync(reclaimPath);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (hasCode(error, "ENOTEMPTY") || hasCode(error, "EEXIST"))
|
|
154
|
+
throw recoveryError();
|
|
155
|
+
if (!hasCode(error, "ENOENT"))
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(reclaimPath, { mode: 0o700 });
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (!hasCode(error, "EEXIST"))
|
|
163
|
+
throw error;
|
|
164
|
+
throw recoveryError();
|
|
165
|
+
}
|
|
166
|
+
const guard = statSync(reclaimPath);
|
|
167
|
+
const markerPath = `${reclaimPath}/${randomUUID()}`;
|
|
168
|
+
const ownsGuard = () => {
|
|
169
|
+
try {
|
|
170
|
+
const current = statSync(reclaimPath);
|
|
171
|
+
return current.dev === guard.dev && current.ino === guard.ino &&
|
|
172
|
+
existsSync(markerPath);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (hasCode(error, "ENOENT"))
|
|
176
|
+
return false;
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const assertGuard = () => {
|
|
181
|
+
if (!ownsGuard() || stale(reclaimPath))
|
|
182
|
+
throw recoveryError();
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
writeFileSync(markerPath, "", { flag: "wx", mode: 0o600 });
|
|
186
|
+
if (!ownsGuard())
|
|
187
|
+
throw recoveryError();
|
|
188
|
+
const now = new Date();
|
|
189
|
+
utimesSync(reclaimPath, now, now);
|
|
190
|
+
const removeStale = refuseLiveHolder();
|
|
191
|
+
assertGuard();
|
|
192
|
+
if (removeStale)
|
|
193
|
+
unlinkSync(lockPath);
|
|
194
|
+
if (!acquire()) {
|
|
195
|
+
refuseLiveHolder();
|
|
196
|
+
throw new Error(`[connecta] state file ${path} lock changed during recovery. Retry opening it.`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
const owned = ownsGuard();
|
|
201
|
+
rmSync(markerPath, { force: true });
|
|
202
|
+
if (owned)
|
|
203
|
+
rmdirSync(reclaimPath);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const assertHeld = () => {
|
|
207
|
+
if (readLock() !== contents) {
|
|
208
|
+
throw new Error(`[connecta] state file ${path} lock was lost. Refusing to write a stale snapshot.`);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
localLocks.set(path, contents);
|
|
212
|
+
const heartbeat = setInterval(() => {
|
|
213
|
+
try {
|
|
214
|
+
assertHeld();
|
|
215
|
+
const now = new Date();
|
|
216
|
+
utimesSync(lockPath, now, now);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// A replaced lock belongs to its new holder. Failed refreshes let the
|
|
220
|
+
// lease expire; subsequent writes still have to prove ownership.
|
|
221
|
+
clearInterval(heartbeat);
|
|
222
|
+
}
|
|
223
|
+
}, HEARTBEAT_MS);
|
|
224
|
+
heartbeat.unref();
|
|
225
|
+
return {
|
|
226
|
+
assertHeld,
|
|
227
|
+
release() {
|
|
228
|
+
clearInterval(heartbeat);
|
|
229
|
+
if (localLocks.get(path) === contents)
|
|
230
|
+
localLocks.delete(path);
|
|
231
|
+
if (readLock() === contents)
|
|
232
|
+
unlinkSync(lockPath);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
3
236
|
/**
|
|
4
237
|
* JSON-file-backed KVStorage for Node. Loads once, persists on every write via
|
|
5
|
-
*
|
|
238
|
+
* an exclusive temp-file + rename. Refuses a second holder of the same path.
|
|
239
|
+
* Call close() when finished to release its lock; process exit also releases it.
|
|
240
|
+
* Only reachable via the "@zackbart/connecta/node"
|
|
6
241
|
* subpath so the main entry stays Workers-clean.
|
|
7
242
|
*/
|
|
8
243
|
export function fileStorage(path, opts = {}) {
|
|
244
|
+
path = resolve(path);
|
|
245
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
246
|
+
const lock = lockFile(path);
|
|
247
|
+
let closed = false;
|
|
248
|
+
const close = () => {
|
|
249
|
+
if (closed)
|
|
250
|
+
return;
|
|
251
|
+
closed = true;
|
|
252
|
+
openStores.delete(close);
|
|
253
|
+
if (!openStores.size)
|
|
254
|
+
process.removeListener("exit", closeStores);
|
|
255
|
+
lock.release();
|
|
256
|
+
};
|
|
257
|
+
const assertOpen = () => {
|
|
258
|
+
if (closed)
|
|
259
|
+
throw new Error(`[connecta] state file ${path} is closed.`);
|
|
260
|
+
};
|
|
9
261
|
const logger = opts.logger ?? console;
|
|
10
262
|
// The state file holds downstream OAuth access/refresh tokens in cleartext,
|
|
11
263
|
// so keep it owner-only. Repair is best-effort: chmod is a no-op or throws on
|
|
@@ -20,51 +272,68 @@ export function fileStorage(path, opts = {}) {
|
|
|
20
272
|
}
|
|
21
273
|
};
|
|
22
274
|
let data = {};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
data = JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
-
}
|
|
28
|
-
catch (error) {
|
|
29
|
-
// Never let a damaged state file be silently replaced by an empty one:
|
|
30
|
-
// the next set() would persist {} over irreplaceable downstream OAuth
|
|
31
|
-
// tokens and credential-vault entries. Quarantine the bytes so they
|
|
32
|
-
// survive for manual recovery, and refuse to start if even that fails —
|
|
33
|
-
// losing the file loudly beats losing it quietly.
|
|
34
|
-
const quarantine = `${path}.corrupt-${Date.now()}`;
|
|
275
|
+
try {
|
|
276
|
+
if (existsSync(path)) {
|
|
277
|
+
tighten();
|
|
35
278
|
try {
|
|
36
|
-
|
|
279
|
+
data = JSON.parse(readFileSync(path, "utf8"));
|
|
37
280
|
}
|
|
38
|
-
catch (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
281
|
+
catch (error) {
|
|
282
|
+
// Never let a damaged state file be silently replaced by an empty one:
|
|
283
|
+
// the next set() would persist {} over irreplaceable downstream OAuth
|
|
284
|
+
// tokens and credential-vault entries. Quarantine the bytes so they
|
|
285
|
+
// survive for manual recovery, and refuse to start if even that fails —
|
|
286
|
+
// losing the file loudly beats losing it quietly.
|
|
287
|
+
const quarantine = `${path}.corrupt-${Date.now()}`;
|
|
288
|
+
try {
|
|
289
|
+
renameSync(path, quarantine);
|
|
290
|
+
}
|
|
291
|
+
catch (renameError) {
|
|
292
|
+
throw new Error(`[connecta] state file ${path} is not valid JSON and could not be ` +
|
|
293
|
+
`moved aside (${String(renameError)}). Refusing to start rather ` +
|
|
294
|
+
`than overwrite it. Move or repair the file, then restart.`);
|
|
295
|
+
}
|
|
296
|
+
logger.error(`[connecta] state file ${path} is not valid JSON ` +
|
|
297
|
+
`(${error instanceof Error ? error.message : String(error)}) — ` +
|
|
298
|
+
`moved to ${quarantine}, starting from empty state. Downstream ` +
|
|
299
|
+
`OAuth connectors must be re-authorized and stored credentials ` +
|
|
300
|
+
`re-entered.`);
|
|
301
|
+
data = {};
|
|
42
302
|
}
|
|
43
|
-
logger.error(`[connecta] state file ${path} is not valid JSON ` +
|
|
44
|
-
`(${error instanceof Error ? error.message : String(error)}) — ` +
|
|
45
|
-
`moved to ${quarantine}, starting from empty state. Downstream ` +
|
|
46
|
-
`OAuth connectors must be re-authorized and stored credentials ` +
|
|
47
|
-
`re-entered.`);
|
|
48
|
-
data = {};
|
|
49
303
|
}
|
|
50
304
|
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
close();
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
if (!openStores.size)
|
|
310
|
+
process.on("exit", closeStores);
|
|
311
|
+
openStores.add(close);
|
|
51
312
|
const persist = () => {
|
|
52
313
|
// Physical expiry rides on an operation that was already going to write.
|
|
53
|
-
//
|
|
54
|
-
// instance may have written newer unrelated values since we loaded it.
|
|
314
|
+
// Reads only prune memory; they do not rewrite the state file.
|
|
55
315
|
const now = Date.now();
|
|
56
316
|
for (const [key, entry] of Object.entries(data)) {
|
|
57
317
|
if (entry.exp && now > entry.exp)
|
|
58
318
|
delete data[key];
|
|
59
319
|
}
|
|
60
|
-
const
|
|
61
|
-
if (dir)
|
|
62
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
63
|
-
const tmp = `${path}.tmp`;
|
|
320
|
+
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
64
321
|
// 0o600 on the tmp file; the atomic rename below preserves it, so the live
|
|
65
322
|
// state file is never briefly world-readable.
|
|
66
|
-
|
|
67
|
-
|
|
323
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
324
|
+
try {
|
|
325
|
+
try {
|
|
326
|
+
writeFileSync(fd, JSON.stringify(data));
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
closeSync(fd);
|
|
330
|
+
}
|
|
331
|
+
lock.assertHeld();
|
|
332
|
+
renameSync(tmp, path);
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
rmSync(tmp, { force: true });
|
|
336
|
+
}
|
|
68
337
|
tighten();
|
|
69
338
|
};
|
|
70
339
|
const fresh = (key) => {
|
|
@@ -78,10 +347,16 @@ export function fileStorage(path, opts = {}) {
|
|
|
78
347
|
return e;
|
|
79
348
|
};
|
|
80
349
|
return {
|
|
350
|
+
close,
|
|
81
351
|
async get(key) {
|
|
352
|
+
// Reads use the loaded snapshot; only writes need filesystem ownership
|
|
353
|
+
// checks to prevent a reclaimed holder from overwriting newer state.
|
|
354
|
+
assertOpen();
|
|
82
355
|
return fresh(key)?.value ?? null;
|
|
83
356
|
},
|
|
84
357
|
async set(key, value, opts) {
|
|
358
|
+
assertOpen();
|
|
359
|
+
lock.assertHeld();
|
|
85
360
|
data[key] = {
|
|
86
361
|
value,
|
|
87
362
|
...(opts?.ttlSeconds
|
|
@@ -91,10 +366,13 @@ export function fileStorage(path, opts = {}) {
|
|
|
91
366
|
persist();
|
|
92
367
|
},
|
|
93
368
|
async delete(key) {
|
|
369
|
+
assertOpen();
|
|
370
|
+
lock.assertHeld();
|
|
94
371
|
delete data[key];
|
|
95
372
|
persist();
|
|
96
373
|
},
|
|
97
374
|
async list(prefix) {
|
|
375
|
+
assertOpen();
|
|
98
376
|
return Object.keys(data)
|
|
99
377
|
.filter((key) => Boolean(fresh(key)) && key.startsWith(prefix))
|
|
100
378
|
.sort();
|
package/dist/storage/memory.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** In-memory KV store with expiry. The default for dev and Node. */
|
|
2
2
|
export function memoryStorage() {
|
|
3
3
|
const map = new Map();
|
|
4
|
+
let sweep = map.keys();
|
|
4
5
|
const fresh = (key) => {
|
|
5
6
|
const e = map.get(key);
|
|
6
7
|
if (!e)
|
|
7
8
|
return null;
|
|
8
|
-
if (e.exp && Date.now()
|
|
9
|
+
if (e.exp !== undefined && Date.now() >= e.exp) {
|
|
9
10
|
map.delete(key);
|
|
10
11
|
return null;
|
|
11
12
|
}
|
|
@@ -16,6 +17,16 @@ export function memoryStorage() {
|
|
|
16
17
|
return fresh(key)?.value ?? null;
|
|
17
18
|
},
|
|
18
19
|
async set(key, value, opts) {
|
|
20
|
+
// Rotate through at most 16 existing keys. Live entries cannot keep an
|
|
21
|
+
// expired tail resident forever, and no request starts a background job.
|
|
22
|
+
for (let i = 0; i < 16; i++) {
|
|
23
|
+
const next = sweep.next();
|
|
24
|
+
if (next.done) {
|
|
25
|
+
sweep = map.keys();
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
fresh(next.value);
|
|
29
|
+
}
|
|
19
30
|
map.set(key, {
|
|
20
31
|
value,
|
|
21
32
|
...(opts?.ttlSeconds
|
package/dist/validate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Validator } from "@cfworker/json-schema";
|
|
2
|
-
import { ConnectorCallError } from "./errors.js";
|
|
2
|
+
import { boundedEchoText, ConnectorCallError } from "./errors.js";
|
|
3
3
|
import { MAX_ARGUMENT_VALIDATION_ISSUES } from "./errors.js";
|
|
4
4
|
// Lazy validator cache keyed by the schema object itself; null marks a schema
|
|
5
5
|
// the validator rejected (warned once, then passed through rather than
|
|
@@ -250,10 +250,10 @@ export function validateToolInput(schema, args, opts) {
|
|
|
250
250
|
if (result && !result.valid) {
|
|
251
251
|
const units = normalizedValidationUnits(schema, result.errors);
|
|
252
252
|
const nestedUnits = units.filter((unit) => unit.instanceLocation !== "#");
|
|
253
|
-
const detail = (nestedUnits.length > 0 ? nestedUnits : units)
|
|
253
|
+
const detail = boundedEchoText((nestedUnits.length > 0 ? nestedUnits : units)
|
|
254
254
|
.slice(0, MAX_ARGUMENT_VALIDATION_ISSUES)
|
|
255
255
|
.map((unit) => `${unit.instanceLocation}: ${agentFacingValidationError(unit)}`)
|
|
256
|
-
.join("; ");
|
|
256
|
+
.join("; "), 256);
|
|
257
257
|
return new ConnectorCallError("invalid_args", `Invalid arguments for "${opts.address}": ${detail || "input does not match the tool's inputSchema"}`, { validation: validationDetails(schema, units) });
|
|
258
258
|
}
|
|
259
259
|
return null;
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -29,6 +29,15 @@ removes only its own wait. The owner's request signal belongs to its token
|
|
|
29
29
|
fetch. Cancelling that owner fails current joiners too because promoting one
|
|
30
30
|
could replay a refresh token the authorization server already consumed.
|
|
31
31
|
|
|
32
|
+
A valid token response is a consumed refresh token whether or not the owner
|
|
33
|
+
survives to save it. The coordinator therefore keeps the accepted tokens on the
|
|
34
|
+
flight, and when the owner fails after that response — cancelled, redirected
|
|
35
|
+
to authorization, or invalidated — it persists the rotation on the host's own
|
|
36
|
+
write, holds contenders behind the pending-mutation marker until that write
|
|
37
|
+
lands, and hands them the saved rotation. No contender ever redeems the retired
|
|
38
|
+
token again, and the marker can no longer outlive the write that clears it
|
|
39
|
+
([#526](https://github.com/zackbart/connecta/issues/526)).
|
|
40
|
+
|
|
32
41
|
The coordinator retains the owner's abort signal only through one temporary
|
|
33
42
|
listener on the exact active refresh. Save, failure, cancellation, or
|
|
34
43
|
generation retirement removes it along with the map entry. It never retains a
|
|
@@ -56,22 +65,24 @@ scope resolved from the request rather than remembered.
|
|
|
56
65
|
|
|
57
66
|
## Request lifecycle
|
|
58
67
|
|
|
59
|
-
`src/server.ts` is the composition root. It
|
|
60
|
-
|
|
68
|
+
`src/server.ts` is the composition root. It checks MCP origins, upgrades the
|
|
69
|
+
scheme when it must, runs the route table, then wraps whatever came back in
|
|
61
70
|
security headers. Route *order* is the contract — several routes would behave
|
|
62
71
|
differently if they were reachable in another order — so the table below is
|
|
63
72
|
read top to bottom.
|
|
64
73
|
|
|
65
74
|
| Order | Route | Notes |
|
|
66
75
|
| --- | --- | --- |
|
|
76
|
+
| 0 | MCP Origin check | `/mcp` and every `/mcp/` suffix reject a disallowed `Origin` with a fixed 403 before redirects, admission, auth, or preflight. `allowedOrigins` defaults to the configured public origin plus HTTP(S) loopback origins at any port. Requests without Origin are admitted. |
|
|
67
77
|
| 0 | HTTPS upgrade | 308 to `publicUrl` when it is HTTPS and the request arrived over HTTP. Path and query are *assigned* onto the configured URL, never resolved against it, so a `//host` pathname cannot replace the deployment origin. `/health` is exempt: a loopback container probe must not depend on public DNS and TLS. `/ui` is canonicalized to `/` while upgrading. |
|
|
68
78
|
| 0 | Cloudflare Access (Worker deployment, when enabled) | Edge admission before this route table. Managed OAuth owns its challenge and discovery metadata; an admitted direct invocation carries trusted identity in `ctx.access`. |
|
|
69
79
|
| 1 | Mounted UI routes | The optional UI handles its shells, assets, data, details, and auth mutations before wildcard OPTIONS. Mutation routes refuse preflight rather than inheriting MCP CORS. No UI module means none of these routes. |
|
|
70
|
-
| 2 | `OPTIONS`
|
|
80
|
+
| 2 | MCP preflight | Allowed `OPTIONS` on `/mcp` or any `/mcp/` suffix returns 204 without admission or auth. Reflect the allowed origin and requested valid `mcp-param-*` header names. |
|
|
81
|
+
| 2 | Other `OPTIONS` | Auth metadata gets a chance, otherwise compatibility CORS preflight. |
|
|
71
82
|
| 3 | `/.well-known/*` | Auth metadata, or 404. |
|
|
72
|
-
| 4 | `/health` | Open payload-free health, executor, admission, and deployment metadata;
|
|
83
|
+
| 4 | `/health` | Open payload-free health, executor, admission, and deployment metadata; connector drift uses stable short hashes and downstream admission sums shared and personal controllers without ids. Reserved routes reflect installed modules. |
|
|
73
84
|
| 5 | `/oauth/callback/<connectorId>` | Core downstream OAuth completion, state verification and personal ownership checks; independent of UI. |
|
|
74
|
-
| 6 | `/mcp`, `/mcp/<pool>` |
|
|
85
|
+
| 6 | `/mcp`, `/mcp/<pool>` | Origin check before admission, admission before auth, then a request-local MCP server. A pool path serves the declared pool intersected with the identity's own view; any undeclared suffix, including malformed names, a refusing grant, and a throwing grant return the same 404 status, body, and headers after auth. Grant lookup latency is not hidden; see [pools](./auth.md#pools). |
|
|
75
86
|
| 7 | Other paths | 404. Custom HTTP routes belong to the deployment. |
|
|
76
87
|
|
|
77
88
|
|
|
@@ -80,7 +91,12 @@ policy, HSTS on HTTPS, while the UI module adds a nonce-based script CSP and fra
|
|
|
80
91
|
and the exact refusal bodies; it exists because the ordering is invisible in
|
|
81
92
|
any one file and a reordering reads like a harmless refactor.
|
|
82
93
|
|
|
83
|
-
`/mcp`
|
|
94
|
+
`/mcp` first checks Origin, including on preflight. A disallowed browser origin
|
|
95
|
+
costs no permit and no auth lookup. The explicit `allowedOrigins: "*"` escape
|
|
96
|
+
hatch preserves open CORS; a list reflects only admitted origins and varies
|
|
97
|
+
responses by Origin. An originless client needs no CORS allow-origin header.
|
|
98
|
+
|
|
99
|
+
An admitted non-preflight request then takes six steps:
|
|
84
100
|
|
|
85
101
|
1. **Admit.** One permit from the deployment-wide FIFO pool, taken before auth
|
|
86
102
|
so an unauthenticated flood costs a permit rather than a Clerk lookup
|
package/documentation/auth.md
CHANGED
|
@@ -6,6 +6,16 @@ Cloudflare Access from `/auth/cloudflare-access`. Providers may be combined;
|
|
|
6
6
|
static bearers are checked first, then other providers in configuration order.
|
|
7
7
|
Connecta no longer issues `cta_` tokens or serves token-management routes.
|
|
8
8
|
|
|
9
|
+
The bearer adapter challenges with `WWW-Authenticate: Bearer` and deliberately
|
|
10
|
+
omits `resource_metadata`. Its credential is configured out of band; it has no
|
|
11
|
+
OAuth authorization server or registration endpoint to advertise. Interactive
|
|
12
|
+
adapters or the edge own OAuth discovery. Every open deployment with at least
|
|
13
|
+
one connector warns at construction, including API connectors with static auth
|
|
14
|
+
headers. Credential and OAuth connectors add explicit wording about those grants.
|
|
15
|
+
|
|
16
|
+
MCP browser origins pass the [Origin check](./request-admission.md#origin-before-admission)
|
|
17
|
+
before admission or auth. This is independent of an identity's tool grants.
|
|
18
|
+
|
|
9
19
|
## Principals, visibility, and operators
|
|
10
20
|
|
|
11
21
|
The actor identifies the caller in activity. The subject owns transient results
|
|
@@ -13,6 +23,14 @@ such as `get_result` pages. The principal is the human owner of personal
|
|
|
13
23
|
connector auth. An interactive Clerk or Access user supplies all three. A
|
|
14
24
|
Cloudflare service identity has an actor and subject but no principal.
|
|
15
25
|
|
|
26
|
+
A subject or user id always selects a result-stash partition, even when the
|
|
27
|
+
provider omits `activityActorNamespace`. An explicit namespace remains the
|
|
28
|
+
partition namespace; without one, Connecta uses `connecta:auth:<provider kind>`.
|
|
29
|
+
Subject ids must be distinct within that namespace. This fallback grants no
|
|
30
|
+
personal-auth ownership and changes no activity attribution. Open deployments
|
|
31
|
+
and providers that return no identity share one result partition. A provider
|
|
32
|
+
that supplies only an explicit principal uses that principal as its subject.
|
|
33
|
+
|
|
16
34
|
`identity.connectorAccess` returns `"all"` or a list of grants. A grant is a
|
|
17
35
|
declared connector id, which opens every tool on it, or a `connector.tool`
|
|
18
36
|
address, which opens that tool alone. Grants are additive, so a bare id beside
|
|
@@ -20,10 +38,15 @@ addresses for the same connector means the whole connector. It governs
|
|
|
20
38
|
discovery and use, and defaults to all connectors.
|
|
21
39
|
|
|
22
40
|
Tool grants are enforced in the scoped registry view, below the catalog
|
|
23
|
-
service,
|
|
24
|
-
`connecta.search` and `connecta.call`, and the
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
service. Since 0.24.2, `search_tools`, `describe_tools`, both call tools, a
|
|
42
|
+
program's `connecta.search`, `connecta.describe`, and `connecta.call`, and the
|
|
43
|
+
connection UI read that filtered tool list. Connector-level discovery, guides,
|
|
44
|
+
and `authorize_connector` retain a connector when any tool on it is granted.
|
|
45
|
+
In particular, a `docs.read` grant permits the `docs` authorization handoff,
|
|
46
|
+
subject to the separate auth-management permissions below. Without any grant
|
|
47
|
+
on `docs`, `authorize_connector` returns the same "Unknown connector" refusal
|
|
48
|
+
as an absent connector. An ungranted tool fails exactly like one the connector
|
|
49
|
+
never had: `unknown_tool`, with no hint that it exists. That is the whole security
|
|
27
50
|
claim, and it lives in one place on purpose. There is no separate endpoint per
|
|
28
51
|
tool set; an identity that should see a narrower slice is a branch in this
|
|
29
52
|
resolver, and a bot that needs its own slice is its own bearer subject.
|
|
@@ -63,10 +86,13 @@ The rules, each of which is a test:
|
|
|
63
86
|
- **Grant defaults to deny.** A pool with no `grant` serves nobody. Only a
|
|
64
87
|
literal `true` admits; any other return, a throw, and an undeclared pool
|
|
65
88
|
name produce one 404 identical in status, body, and headers, so a
|
|
66
|
-
credential does not enumerate the other pools by response.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
credential does not enumerate the other pools by response content. Timing
|
|
90
|
+
is explicitly not hidden: a declared name awaits its grant, while an
|
|
91
|
+
undeclared name returns without that lookup. We accept this pool-name
|
|
92
|
+
oracle because names grant no access, a fixed delay cannot hide unbounded
|
|
93
|
+
grant I/O, and invoking grants for unknown names would add avoidable work
|
|
94
|
+
while holding an admission permit. Keep grants pure and fast; do not treat
|
|
95
|
+
pool names as secrets. The operator log carries the refusal reason.
|
|
70
96
|
- **Structural mistakes throw at construction.** A malformed name, an
|
|
71
97
|
unknown connector, an empty pool, and a `connector.tool` address an
|
|
72
98
|
`api()` connector's static catalog lacks all refuse to boot. Remote
|
|
@@ -78,7 +104,9 @@ The rules, each of which is a test:
|
|
|
78
104
|
Cloudflare Managed OAuth is application-level and needs nothing.
|
|
79
105
|
|
|
80
106
|
A `connector.tool` address the live catalog does not contain is unreachable
|
|
81
|
-
and warned once
|
|
107
|
+
and warned once while its address remains in a 1,024-entry FIFO. An evicted
|
|
108
|
+
address may warn again; caller-derived grant text cannot grow retained warning
|
|
109
|
+
state without bound. Remote catalogs load lazily, so construction
|
|
82
110
|
cannot check it, and a catalog that drifts later can never widen a grant
|
|
83
111
|
because there is no wildcard: every tool grant is an exact name.
|
|
84
112
|
|
|
@@ -231,6 +259,11 @@ secret-free handoff to the connection UI. Without the UI, that recovery is
|
|
|
231
259
|
interactive MCP caller can still start downstream OAuth through
|
|
232
260
|
`authorize_connector` without the UI. Core owns the callback and verifies state
|
|
233
261
|
and principal ownership independently of the optional browser application.
|
|
262
|
+
A browser returning from downstream consent normally carries no MCP
|
|
263
|
+
Authorization header, so an interactive bearer provider's 401 does not reject
|
|
264
|
+
the callback. The verified state and its saved principal handoff select the
|
|
265
|
+
owner; a browser identity, when present, must match that owner and may manage
|
|
266
|
+
the connector. An interactive provider's explicit 403 still refuses the flow.
|
|
234
267
|
|
|
235
268
|
See [meta-tools](./meta-tools.md#authorization-recovery) and
|
|
236
269
|
[storage and credentials](./storage-and-credentials.md). The
|