@zackbart/connecta 0.24.2 → 0.24.4

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 (97) hide show
  1. package/AGENTS.md +18 -20
  2. package/CHANGELOG.md +205 -1
  3. package/README.md +5 -6
  4. package/dist/auth/bearer.js +2 -0
  5. package/dist/auth/downstream-oauth.d.ts +12 -1
  6. package/dist/auth/downstream-oauth.js +147 -35
  7. package/dist/branding.d.ts +31 -2
  8. package/dist/branding.js +116 -8
  9. package/dist/call-admission.d.ts +4 -0
  10. package/dist/call-admission.js +26 -0
  11. package/dist/catalog-drift.js +9 -4
  12. package/dist/catalog-service.d.ts +2 -0
  13. package/dist/catalog-service.js +25 -8
  14. package/dist/catalog.d.ts +2 -0
  15. package/dist/catalog.js +246 -121
  16. package/dist/connectors/api.d.ts +1 -1
  17. package/dist/connectors/api.js +21 -3
  18. package/dist/connectors/guarded-fetch.d.ts +6 -2
  19. package/dist/connectors/guarded-fetch.js +61 -24
  20. package/dist/connectors/remote-mcp.js +92 -57
  21. package/dist/errors.d.ts +28 -3
  22. package/dist/errors.js +60 -1
  23. package/dist/execute.js +85 -23
  24. package/dist/executor-result.js +3 -1
  25. package/dist/executors/quickjs-child.js +5 -1
  26. package/dist/executors/quickjs-protocol.d.ts +4 -0
  27. package/dist/executors/quickjs-runtime.d.ts +1 -1
  28. package/dist/executors/quickjs-runtime.js +38 -21
  29. package/dist/executors/quickjs.js +68 -27
  30. package/dist/index.d.ts +15 -1
  31. package/dist/index.js +36 -4
  32. package/dist/invocation.js +134 -93
  33. package/dist/mcp-result.js +3 -2
  34. package/dist/meta-tools.js +191 -36
  35. package/dist/operator-ui/generated.js +2 -2
  36. package/dist/operator-ui/view.d.ts +38 -1
  37. package/dist/operator-ui/view.js +71 -0
  38. package/dist/providers/cloudflare.d.ts +14 -2
  39. package/dist/providers/cloudflare.js +107 -16
  40. package/dist/providers/linear.d.ts +26 -4
  41. package/dist/providers/linear.js +19 -4
  42. package/dist/providers/mixpanel.d.ts +16 -3
  43. package/dist/providers/mixpanel.js +13 -2
  44. package/dist/providers/notion.d.ts +8 -1
  45. package/dist/providers/notion.js +83 -10
  46. package/dist/providers/revenuecat.d.ts +30 -4
  47. package/dist/providers/revenuecat.js +42 -4
  48. package/dist/providers/stripe.d.ts +7 -1
  49. package/dist/providers/stripe.js +30 -4
  50. package/dist/providers/vercel.js +11 -1
  51. package/dist/registry.d.ts +22 -2
  52. package/dist/registry.js +101 -13
  53. package/dist/routes/mcp.d.ts +4 -1
  54. package/dist/routes/mcp.js +84 -13
  55. package/dist/routes/oauth.js +4 -0
  56. package/dist/routes/shared.d.ts +1 -0
  57. package/dist/routes/shared.js +4 -4
  58. package/dist/server.js +15 -3
  59. package/dist/skills.js +6 -5
  60. package/dist/storage/file.d.ts +6 -2
  61. package/dist/storage/file.js +312 -34
  62. package/dist/storage/memory.js +12 -1
  63. package/dist/types.d.ts +37 -0
  64. package/dist/ui.js +18 -10
  65. package/dist/validate.js +3 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/documentation/architecture.md +193 -165
  69. package/documentation/auth.md +199 -145
  70. package/documentation/code-mode.md +433 -316
  71. package/documentation/meta-tools.md +363 -355
  72. package/examples/worker/AGENTS.md +2 -1
  73. package/examples/worker/README.md +12 -10
  74. package/examples/worker/src/index.ts +12 -15
  75. package/package.json +1 -2
  76. package/templates/node/.env.example +3 -3
  77. package/templates/node/AGENTS.md +5 -4
  78. package/templates/node/README.md +2 -1
  79. package/templates/node/package.json +1 -1
  80. package/templates/node/src/index.ts +23 -22
  81. package/documentation/call-admission.md +0 -142
  82. package/documentation/cloudflare.md +0 -471
  83. package/documentation/connector-guides.md +0 -176
  84. package/documentation/connectors.md +0 -389
  85. package/documentation/linear.md +0 -193
  86. package/documentation/mixpanel.md +0 -160
  87. package/documentation/notion.md +0 -308
  88. package/documentation/operations.md +0 -359
  89. package/documentation/operator-ui.md +0 -135
  90. package/documentation/optional-modules-upgrade.md +0 -243
  91. package/documentation/provider-conventions.md +0 -722
  92. package/documentation/request-admission.md +0 -170
  93. package/documentation/revenuecat.md +0 -305
  94. package/documentation/storage-and-credentials.md +0 -201
  95. package/documentation/stripe.md +0 -262
  96. package/documentation/upgrading.md +0 -754
  97. package/documentation/vercel.md +0 -241
@@ -52,20 +52,20 @@ export async function authorize(request, baseUrl, auth, runtimeContext, identity
52
52
  if (result.ok) {
53
53
  const subjectId = result.subjectId ?? result.userId;
54
54
  const actorNamespace = activityActorNamespace(provider);
55
- const subject = subjectId && actorNamespace
56
- ? { namespace: actorNamespace, id: subjectId }
57
- : undefined;
58
55
  const derivedPrincipal = result.userId && actorNamespace
59
56
  ? { namespace: actorNamespace, id: result.userId }
60
57
  : undefined;
61
58
  const principal = validIdentityReference(result.principal)
62
59
  ? result.principal
63
60
  : derivedPrincipal;
61
+ const subject = subjectId
62
+ ? { namespace: actorNamespace ?? `connecta:auth:${provider.kind}`, id: subjectId }
63
+ : principal;
64
64
  const interactive = Boolean(result.userId && provider.interactiveOperator);
65
65
  const actor = {
66
66
  kind: provider.kind,
67
67
  ...(subjectId ? { id: subjectId } : {}),
68
- ...(subject ? { namespace: subject.namespace } : {}),
68
+ ...(subjectId && actorNamespace ? { namespace: actorNamespace } : {}),
69
69
  };
70
70
  const identity = {
71
71
  actor,
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
1
2
  import { isAdmittingExecutor } from "./executor-admission.js";
2
3
  import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
3
4
  import { routeOAuthCallback, } from "./routes/oauth.js";
@@ -18,6 +19,9 @@ export function createFetchHandler(opts) {
18
19
  const defer = runtimeContext
19
20
  ? runtimeContext.waitUntil.bind(runtimeContext)
20
21
  : undefined;
22
+ const originRefusal = routeMcp.rejectOrigin(request);
23
+ if (originRefusal)
24
+ return withSecurityHeaders(originRefusal, url, path);
21
25
  // Container and orchestrator probes reach /health over plain HTTP on
22
26
  // loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
23
27
  // the public origin would make an internal liveness check depend on
@@ -59,6 +63,9 @@ export function createFetchHandler(opts) {
59
63
  if (uiResponse)
60
64
  return uiResponse;
61
65
  if (request.method === "OPTIONS") {
66
+ const preflight = await routeMcp.handle(context);
67
+ if (preflight)
68
+ return preflight;
62
69
  for (const provider of auth) {
63
70
  if (provider.handleMetadata) {
64
71
  const response = await provider.handleMetadata(request, baseUrl);
@@ -102,14 +109,19 @@ export function createFetchHandler(opts) {
102
109
  // Counts only, from refreshes that already happened — the endpoint
103
110
  // asks no downstream anything, and `connecta doctor` reads it to
104
111
  // report a stale allowlist without a probe of its own (#343).
105
- catalogDrift: registry.catalogDriftSnapshot(),
112
+ // Stable 64-bit hashes preserve that shape without publishing ids.
113
+ catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
114
+ const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
115
+ const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
116
+ return [key, report];
117
+ }))),
106
118
  admission: {
107
119
  policy: "global-fifo",
108
120
  requests: opts.requestAdmission.snapshot(),
109
121
  code: codeAdmission ?? { managedByExecutor: true },
110
122
  downstreamCalls: {
111
123
  policy: "connector-partitioned-per-runtime",
112
- connectors: registry.callAdmissionSnapshot(),
124
+ aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
113
125
  },
114
126
  reservedRoutes: [
115
127
  "/health",
@@ -123,7 +135,7 @@ export function createFetchHandler(opts) {
123
135
  const oauthCallback = await routeOAuthCallback(context);
124
136
  if (oauthCallback)
125
137
  return oauthCallback;
126
- const mcp = await routeMcp(context);
138
+ const mcp = await routeMcp.handle(context);
127
139
  if (mcp)
128
140
  return mcp;
129
141
  return new Response("Not Found", { status: 404 });
package/dist/skills.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { boundedEchoText } from "./errors.js";
1
2
  export const CONNECTA_INSTRUCTIONS = 'Choose a route before discovery. A known-address read needs only call_tool. Unknown-address read-only work starts with execute_code to discover, call, and return the answer; use the same route for reduction, multiple or dependent calls, loops, joins, or branches. Keep discovery and calls together when schemas suffice; do not return catalog matches alone. Inspect unfamiliar result shapes with a small sample before proceeding. Only readOnlyHint: true tools run there. Keep catalog inspection and unannotated, write-capable, or destructive work top level: search_tools then call_destructive_tool when a call is needed. After auth_required use authorize_connector. After a truncated direct result use get_result. Guidance is on demand: fetch skills({ name: "usage" }) only when these instructions and the tool description are insufficient or a run needs repair.';
2
3
  const USAGE_SKILL_BASE = `# Connecta usage
3
4
 
@@ -423,14 +424,14 @@ export function resolveSkill(name, connectors) {
423
424
  if (!connector) {
424
425
  return {
425
426
  found: false,
426
- message: `Unknown connector "${id}". Available skills: ${available()}.`,
427
+ message: `Unknown connector "${boundedEchoText(id)}". Available skills: ${available()}.`,
427
428
  };
428
429
  }
429
430
  const guide = connectorGuide(connector);
430
431
  if (!guide) {
431
432
  return {
432
433
  found: false,
433
- message: `Connector "${id}" has no usage guide. Available skills: ${available()}.`,
434
+ message: `Connector "${boundedEchoText(id)}" has no usage guide. Available skills: ${available()}.`,
434
435
  };
435
436
  }
436
437
  return { found: true, content: guide };
@@ -440,12 +441,12 @@ export function resolveSkill(name, connectors) {
440
441
  return {
441
442
  found: false,
442
443
  message: connectorGuide(bare)
443
- ? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available skills: ${available()}.`
444
- : `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
444
+ ? `Unknown skill "${boundedEchoText(name)}". Connector guides are fetched as "${boundedEchoText(connectorSkillName(name))}". Available skills: ${available()}.`
445
+ : `Connector "${boundedEchoText(name)}" has no usage guide. Available skills: ${available()}.`,
445
446
  };
446
447
  }
447
448
  return {
448
449
  found: false,
449
- message: `Unknown skill "${name}". Available skills: ${available()}.`,
450
+ message: `Unknown skill "${boundedEchoText(name)}". Available skills: ${available()}.`,
450
451
  };
451
452
  }
@@ -5,7 +5,11 @@ export interface FileStorageOptions {
5
5
  }
6
6
  /**
7
7
  * JSON-file-backed KVStorage for Node. Loads once, persists on every write via
8
- * a temp-file + rename (atomic-ish). Only reachable via the "@zackbart/connecta/node"
8
+ * an exclusive temp-file + rename. Refuses a second holder of the same path.
9
+ * Call close() when finished to release its lock; process exit also releases it.
10
+ * Only reachable via the "@zackbart/connecta/node"
9
11
  * subpath so the main entry stays Workers-clean.
10
12
  */
11
- export declare function fileStorage(path: string, opts?: FileStorageOptions): KVStorage;
13
+ export declare function fileStorage(path: string, opts?: FileStorageOptions): KVStorage & {
14
+ close(): void;
15
+ };
@@ -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/types.d.ts CHANGED
@@ -519,6 +519,43 @@ export interface ConnectaBranding {
519
519
  };
520
520
  /** `theme-color` meta value. Defaults to "#ffffff". */
521
521
  themeColor?: string;
522
+ /**
523
+ * Operator-page appearance. Every field is optional, and a rejected value
524
+ * takes its default rather than failing the page, so `createConnecta` warns
525
+ * at startup about anything it dropped.
526
+ *
527
+ * Five knobs, not a palette. Surfaces, borders, muted text, and the status
528
+ * colors all derive from these, so a deployment sets an accent and gets a
529
+ * readable page instead of thirty chances to break one.
530
+ */
531
+ theme?: ConnectaTheme;
532
+ }
533
+ /**
534
+ * The operator UI's themeable tokens. These land in a `:root` block on the
535
+ * page, so each is gated by a narrow syntactic check: an unvalidated value
536
+ * here would let deployment config write arbitrary CSS.
537
+ */
538
+ export interface ConnectaTheme {
539
+ /**
540
+ * The brand color: links, focus rings, primary actions, the active nav item.
541
+ * Hex only (`#rgb`, `#rrggbb`, or `#rrggbbaa`). Defaults to `#2f5fe0`.
542
+ */
543
+ accent?: string;
544
+ /**
545
+ * Corner rounding for cards, inputs, and buttons. A CSS length (`10px`,
546
+ * `0.5rem`) or a bare number read as pixels. `0` restores square corners.
547
+ * Defaults to `10px`.
548
+ */
549
+ radius?: string | number;
550
+ /** Body font stack. A plain CSS font-family list. Defaults to a system stack. */
551
+ fontFamily?: string;
552
+ /** Monospace font stack for addresses, ids, and the endpoint URL. */
553
+ monoFamily?: string;
554
+ /**
555
+ * `"system"` (the default) follows the operator's OS setting; `"light"` and
556
+ * `"dark"` pin the page to one palette.
557
+ */
558
+ colorScheme?: "system" | "light" | "dark";
522
559
  }
523
560
  /** An inbound authentication provider (bearer token, interactive identity, ...). */
524
561
  export interface InboundAuth {
package/dist/ui.js CHANGED
@@ -8,7 +8,7 @@ import { mapSettledWithConcurrency, resolveDiscoveryConcurrency, } from "./concu
8
8
  import { OPERATOR_UI_CSS, OPERATOR_UI_SCRIPT, } from "./operator-ui/generated.js";
9
9
  import { CONNECTA_VERSION } from "./version.js";
10
10
  export { filterUiConnectors, } from "./operator-ui/model.js";
11
- import { resolveBranding, isSafeHttpsUrl } from "./branding.js";
11
+ import { resolveBranding, isSafeHttpsUrl, themeCss } from "./branding.js";
12
12
  export { CONNECTA_FAVICON_SVG, resolveBranding, isSafeHttpUrl, isSafeHttpsUrl, isSafeIconHref } from "./branding.js";
13
13
  /**
14
14
  * A JS string literal safe to inline in a script element. Escaping `/` keeps
@@ -281,8 +281,14 @@ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "c
281
281
  const clerkScript = clerk && clerkScriptOrigin
282
282
  ? `<script${nonceAttr} crossorigin="anonymous" data-clerk-publishable-key="${escapeHtmlAttr(clerk.publishableKey)}" src="${escapeHtmlAttr(clerkScriptOrigin)}/npm/@clerk/clerk-js@6/dist/clerk.browser.js"></script>`
283
283
  : "";
284
+ // A pinned scheme is an attribute, not a stylesheet edit: the dark palette
285
+ // keys off `html[data-scheme]`, so "system" leaves the attribute off and the
286
+ // media query decides.
287
+ const schemeAttr = brand.theme.colorScheme === "system"
288
+ ? ""
289
+ : ` data-scheme="${brand.theme.colorScheme}"`;
284
290
  return `<!doctype html>
285
- <html lang="en">
291
+ <html lang="en"${schemeAttr}>
286
292
  <head>
287
293
  <meta charset="utf-8">
288
294
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -292,22 +298,24 @@ export function renderUiHtml(uiAuth, mcpUrl = "/mcp", branding, nonce, page = "c
292
298
  <link rel="shortcut icon" href="/favicon.ico">
293
299
  <title>${escapeHtmlAttr(title)}</title>
294
300
  ${clerkScript}
295
- <style>${OPERATOR_UI_CSS}</style>
301
+ <style>${OPERATOR_UI_CSS}${themeCss(brand.theme)}</style>
296
302
  </head>
297
303
  <body>
298
304
  <a class="skip-link" href="#operatorContent">Skip to operator page</a>
299
- <header class="masthead shell pgrid">
300
- ${owner}
301
- <div class="mast-nav">
302
- ${product}
305
+ <header class="masthead shell">
306
+ <div class="masthead-inner">
307
+ <div class="mast-nav">
308
+ ${owner}
309
+ ${product}
310
+ </div>
303
311
  <div id="operatorNav"></div>
304
312
  </div>
305
313
  </header>
306
314
 
307
315
  <main id="operatorContent" class="page shell" tabindex="-1">
308
- <div class="lead pgrid">
309
- <h1 class="pcap">${OPERATOR_PAGE_LABELS[page]}</h1>
310
- <div class="pbody lead-copy">
316
+ <div class="lead">
317
+ <h1>${OPERATOR_PAGE_LABELS[page]}</h1>
318
+ <div class="lead-copy">
311
319
  <p>${escapeHtmlAttr(brand.description)}</p>
312
320
  <noscript><p class="msg">The operator pages need JavaScript. Nothing else here
313
321
  does — agents reach this deployment through <span class="mono">/mcp</span>.</p></noscript>