@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/dist/auth/bearer.js +2 -0
  3. package/dist/auth/downstream-oauth.d.ts +12 -1
  4. package/dist/auth/downstream-oauth.js +147 -35
  5. package/dist/call-admission.d.ts +4 -0
  6. package/dist/call-admission.js +26 -0
  7. package/dist/catalog-drift.js +9 -4
  8. package/dist/catalog-service.d.ts +2 -0
  9. package/dist/catalog-service.js +25 -8
  10. package/dist/catalog.d.ts +2 -0
  11. package/dist/catalog.js +246 -121
  12. package/dist/connectors/api.js +11 -1
  13. package/dist/connectors/guarded-fetch.d.ts +1 -1
  14. package/dist/connectors/guarded-fetch.js +27 -20
  15. package/dist/connectors/remote-mcp.js +84 -53
  16. package/dist/errors.d.ts +17 -0
  17. package/dist/errors.js +58 -0
  18. package/dist/execute.js +85 -23
  19. package/dist/executor-result.js +3 -1
  20. package/dist/executors/quickjs-child.js +5 -1
  21. package/dist/executors/quickjs-protocol.d.ts +4 -0
  22. package/dist/executors/quickjs-runtime.d.ts +1 -1
  23. package/dist/executors/quickjs-runtime.js +38 -21
  24. package/dist/executors/quickjs.js +68 -27
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +24 -3
  27. package/dist/invocation.js +134 -93
  28. package/dist/mcp-result.js +3 -2
  29. package/dist/meta-tools.js +118 -39
  30. package/dist/registry.d.ts +14 -2
  31. package/dist/registry.js +87 -13
  32. package/dist/routes/mcp.d.ts +4 -1
  33. package/dist/routes/mcp.js +84 -13
  34. package/dist/routes/oauth.js +4 -0
  35. package/dist/routes/shared.d.ts +1 -0
  36. package/dist/routes/shared.js +4 -4
  37. package/dist/server.js +15 -3
  38. package/dist/skills.js +6 -5
  39. package/dist/storage/file.d.ts +6 -2
  40. package/dist/storage/file.js +312 -34
  41. package/dist/storage/memory.js +12 -1
  42. package/dist/validate.js +3 -3
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/documentation/architecture.md +22 -6
  46. package/documentation/auth.md +42 -9
  47. package/documentation/call-admission.md +24 -8
  48. package/documentation/code-mode.md +34 -22
  49. package/documentation/connectors.md +47 -5
  50. package/documentation/meta-tools.md +74 -6
  51. package/documentation/operations.md +19 -19
  52. package/documentation/provider-conventions.md +7 -0
  53. package/documentation/request-admission.md +38 -4
  54. package/documentation/storage-and-credentials.md +54 -1
  55. package/documentation/upgrading.md +18 -4
  56. package/package.json +1 -1
  57. 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.2";
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.2";
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`, `/mcp/<pool>` | Admission before auth, then a request-local MCP server. A pool path serves the declared pool intersected with the identity's own view; an undeclared name, a refusing grant, and a throwing grant are one identical 404. |
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 six 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
@@ -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, so `search_tools`, `describe_tools`, both call tools, a program's
24
- `connecta.search` and `connecta.call`, and the connection UI all read the same
25
- filtered list. An ungranted tool fails exactly like one the connector never
26
- had: `unknown_tool`, with no hint that it exists. That is the whole security
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. Keep grants
67
- pure and fast: a grant that does I/O is the one thing that could make a
68
- declared pool distinguishable from an undeclared one by timing. The
69
- operator log carries the reason.
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 per isolate. Remote catalogs load lazily, so construction
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