@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/clerk.d.ts +0 -5
  4. package/dist/auth/clerk.js +21 -8
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/call-admission.d.ts +4 -0
  8. package/dist/call-admission.js +26 -0
  9. package/dist/catalog-drift.js +9 -4
  10. package/dist/catalog-service.d.ts +2 -0
  11. package/dist/catalog-service.js +25 -8
  12. package/dist/catalog.d.ts +2 -0
  13. package/dist/catalog.js +246 -121
  14. package/dist/connector-access.d.ts +32 -0
  15. package/dist/connector-access.js +79 -0
  16. package/dist/connectors/api.js +11 -1
  17. package/dist/connectors/guarded-fetch.d.ts +1 -1
  18. package/dist/connectors/guarded-fetch.js +27 -20
  19. package/dist/connectors/remote-mcp.js +84 -53
  20. package/dist/errors.d.ts +17 -0
  21. package/dist/errors.js +58 -0
  22. package/dist/execute.js +85 -23
  23. package/dist/executor-result.js +3 -1
  24. package/dist/executors/quickjs-child.js +5 -1
  25. package/dist/executors/quickjs-protocol.d.ts +4 -0
  26. package/dist/executors/quickjs-runtime.d.ts +1 -1
  27. package/dist/executors/quickjs-runtime.js +38 -21
  28. package/dist/executors/quickjs.js +68 -27
  29. package/dist/index.d.ts +37 -1
  30. package/dist/index.js +89 -3
  31. package/dist/invocation.js +134 -93
  32. package/dist/mcp-result.js +3 -2
  33. package/dist/meta-tools.js +118 -39
  34. package/dist/registry.d.ts +29 -1
  35. package/dist/registry.js +122 -15
  36. package/dist/routes/credentials.js +1 -0
  37. package/dist/routes/mcp.d.ts +4 -1
  38. package/dist/routes/mcp.js +112 -12
  39. package/dist/routes/oauth-management.js +1 -0
  40. package/dist/routes/oauth.js +4 -0
  41. package/dist/routes/shared.d.ts +7 -1
  42. package/dist/routes/shared.js +12 -13
  43. package/dist/routes/ui.js +2 -1
  44. package/dist/server.js +15 -3
  45. package/dist/skills.js +6 -5
  46. package/dist/storage/file.d.ts +6 -2
  47. package/dist/storage/file.js +312 -34
  48. package/dist/storage/memory.js +12 -1
  49. package/dist/validate.js +3 -3
  50. package/dist/version.d.ts +1 -1
  51. package/dist/version.js +1 -1
  52. package/documentation/architecture.md +30 -9
  53. package/documentation/auth.md +110 -6
  54. package/documentation/call-admission.md +24 -8
  55. package/documentation/code-mode.md +34 -22
  56. package/documentation/connectors.md +47 -5
  57. package/documentation/meta-tools.md +74 -6
  58. package/documentation/operations.md +20 -19
  59. package/documentation/provider-conventions.md +7 -0
  60. package/documentation/request-admission.md +38 -4
  61. package/documentation/storage-and-credentials.md +54 -1
  62. package/documentation/upgrading.md +21 -5
  63. package/ethos.md +1 -1
  64. package/package.json +1 -1
  65. package/templates/node/package.json +1 -1
@@ -1,11 +1,263 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
- import { dirname } from "node:path";
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
- * a temp-file + rename (atomic-ish). Only reachable via the "@zackbart/connecta/node"
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
- if (existsSync(path)) {
24
- tighten();
25
- try {
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
- renameSync(path, quarantine);
279
+ data = JSON.parse(readFileSync(path, "utf8"));
37
280
  }
38
- catch (renameError) {
39
- throw new Error(`[connecta] state file ${path} is not valid JSON and could not be ` +
40
- `moved aside (${String(renameError)}). Refusing to start rather ` +
41
- `than overwrite it. Move or repair the file, then restart.`);
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
- // A read must not flush this instance's load-once snapshot: another live
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 dir = dirname(path);
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
- writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
67
- renameSync(tmp, path);
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();
@@ -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() > e.exp) {
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
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export declare const CONNECTA_VERSION = "0.24.1";
7
+ export declare const CONNECTA_VERSION = "0.24.3";
package/dist/version.js CHANGED
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.24.1";
7
+ export const CONNECTA_VERSION = "0.24.3";
@@ -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 does three things in order: upgrade
60
- the scheme when it must, run the route table, then wrap whatever came back in
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` | Auth metadata gets a chance, otherwise MCP CORS preflight. |
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; reserved routes reflect installed modules. |
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` | Admission before auth, then a request-local MCP server. |
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` itself is five steps, in this order and for these reasons:
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. Personal connectors use the principal partition; result paging uses
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. **Refuse `?toolkit=`.** Caller-selected toolkits were removed ([#178](https://github.com/zackbart/connecta/issues/178))
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
- 5. **Serve.** A fresh `McpServer` per request, the seven meta-tools registered
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