@zackbart/connecta 0.24.1 → 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 +169 -0
- package/dist/auth/bearer.js +2 -0
- package/dist/auth/clerk.d.ts +0 -5
- package/dist/auth/clerk.js +21 -8
- 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/connector-access.d.ts +32 -0
- package/dist/connector-access.js +79 -0
- 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 +37 -1
- package/dist/index.js +89 -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 +29 -1
- package/dist/registry.js +122 -15
- package/dist/routes/credentials.js +1 -0
- package/dist/routes/mcp.d.ts +4 -1
- package/dist/routes/mcp.js +112 -12
- package/dist/routes/oauth-management.js +1 -0
- package/dist/routes/oauth.js +4 -0
- package/dist/routes/shared.d.ts +7 -1
- package/dist/routes/shared.js +12 -13
- package/dist/routes/ui.js +2 -1
- 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 +30 -9
- package/documentation/auth.md +110 -6
- 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 +20 -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 +21 -5
- package/ethos.md +1 -1
- 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
|
|
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
|
|
@@ -92,13 +108,18 @@ any one file and a reordering reads like a harmless refactor.
|
|
|
92
108
|
only, and it warns at construction.
|
|
93
109
|
3. **Derive the registry view.** Auth supplies a namespaced subject and, for a
|
|
94
110
|
human, a principal. `identity.connectorAccess` selects declared connector
|
|
95
|
-
ids
|
|
111
|
+
ids and, for a narrower slice, exact `connector.tool` addresses; the
|
|
112
|
+
scoped view filters every catalog read through them. Personal connectors use the principal partition; result paging uses
|
|
96
113
|
the subject partition. No caller parameter selects either.
|
|
97
|
-
4. **
|
|
114
|
+
4. **Narrow to the pool.** On `/mcp/<pool>`, look the name up in the
|
|
115
|
+
declared pools and run its grant against the authenticated identity. The
|
|
116
|
+
view becomes the pool intersected with the identity's `connectorAccess`;
|
|
117
|
+
a pool can never widen it. Anything else is a 404 that names no pool.
|
|
118
|
+
5. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
|
|
98
119
|
but the URLs naming them were handed out, so the parameter is a 404 rather
|
|
99
120
|
than silently serving the full registry. Retiring a scoping boundary into
|
|
100
121
|
fail-open is the one outcome worse than the 404.
|
|
101
|
-
|
|
122
|
+
6. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
|
|
102
123
|
against the registry and the response
|
|
103
124
|
handed back.
|
|
104
125
|
|