@crossgen-ai/praxis-connectors 0.1.2

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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @crossgen-ai/praxis-connectors
2
+
3
+ An MCP connector engine for the Praxis platform.
4
+
5
+ This is an internal package published for use by CrossGen-AI's own
6
+ applications. It is not intended for general use, and its API may change
7
+ without notice.
8
+
9
+ ## License
10
+
11
+ MIT
@@ -0,0 +1,99 @@
1
+ // The pi extension face: registers every
2
+ // cached connector tool as mcp__<connector>__<tool>. Loadable two ways —
3
+ // · a harness session lists it via settings.json extensions[] (through a
4
+ // shim that keeps the worker gate), and
5
+ // · a scheduled run gets it via an explicit `pi -e <this file>`.
6
+ // Tool lists come from the store's cache, so a cold connector never blocks
7
+ // the spawn; connections open lazily on first call.
8
+ //
9
+ // Scoping: PRAXIS_CONNECTORS_ONLY (comma-separated ids) narrows a run to
10
+ // its task's connector list — the driver sets it per spawn.
11
+ 'use strict';
12
+
13
+ const { ConnectorsEngine } = require('../lib/engine');
14
+
15
+ // The name mint mirrors the harness bridge's so the content-guard
16
+ // mcp__ scanning lattice applies to connector tools unchanged.
17
+ function toolName(connectorId, tool) {
18
+ const clean = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, '_');
19
+ return `mcp__${clean(connectorId)}__${clean(tool)}`;
20
+ }
21
+
22
+ // Content-guard's registration-time channels listen
23
+ // on this event; emitting it costs nothing under bare pi. The literal string
24
+ // is the public contract — importing harness code here would break the seam.
25
+ const MCP_TOOL_DISCOVERED_EVENT = 'praxis.mcp.tool.discovered';
26
+
27
+ function scopedIds() {
28
+ const only = String(process.env.PRAXIS_CONNECTORS_ONLY || '').trim();
29
+ if (!only) return null;
30
+ return new Set(only.split(',').map((s) => s.trim()).filter(Boolean));
31
+ }
32
+
33
+ // Under pi, typebox is aliased to the running agent's copy and Type.Unsafe
34
+ // is the sanctioned wrapper for raw JSON schema (the bridge does the same).
35
+ // Under plain node (this package's tests), typebox may be absent — the raw
36
+ // schema object is the same shape minus TypeBox's kind marker.
37
+ let wrapSchema = (schema) => schema;
38
+ try {
39
+ // eslint-disable-next-line global-require
40
+ const { Type } = require('typebox');
41
+ wrapSchema = (schema) => Type.Unsafe(schema);
42
+ } catch { /* raw JSON schema */ }
43
+
44
+ function connectorsExtension(pi) {
45
+ const engine = new ConnectorsEngine({
46
+ onLog: (line) => process.stderr.write(`[connectors] ${line}\n`),
47
+ });
48
+ const scope = scopedIds();
49
+ let registered = 0;
50
+
51
+ for (const entry of engine.list()) {
52
+ if (scope && !scope.has(entry.id)) continue;
53
+ for (const tool of engine.cachedTools(entry.id)) {
54
+ const name = toolName(entry.id, tool.name);
55
+ const inputSchema = tool.inputSchema || { type: 'object' };
56
+ pi.events?.emit?.(MCP_TOOL_DISCOVERED_EVENT, {
57
+ plugin: 'connectors',
58
+ server: entry.id,
59
+ tool: tool.name,
60
+ registeredName: name,
61
+ description: tool.description,
62
+ inputSchema,
63
+ });
64
+ try {
65
+ pi.registerTool({
66
+ name,
67
+ label: `${entry.name}: ${tool.name}`,
68
+ description: tool.description || `${tool.name} on the ${entry.name} connector`,
69
+ parameters: wrapSchema({ ...inputSchema, type: 'object' }),
70
+ execute: async (_toolCallId, params) => {
71
+ const result = await engine.callTool(entry.id, tool.name, params ?? {});
72
+ return {
73
+ content: [{ type: 'text', text: result.text }],
74
+ details: {},
75
+ isError: result.isError,
76
+ };
77
+ },
78
+ });
79
+ registered += 1;
80
+ } catch (err) {
81
+ process.stderr.write(`[connectors] could not register ${name}: ${String(err?.message || err)}\n`);
82
+ }
83
+ }
84
+ }
85
+
86
+ if (registered > 0) {
87
+ process.stderr.write(`[connectors] ${registered} connector tool${registered === 1 ? '' : 's'} available\n`);
88
+ }
89
+
90
+ pi.on?.('session_shutdown', async () => {
91
+ await engine.close();
92
+ });
93
+ }
94
+
95
+ module.exports = connectorsExtension;
96
+ // The mint is exported so the harness gate can pin it against the plugins
97
+ // bridge's (they must agree for content-guard's mcp__ lattice to be one).
98
+ module.exports.toolName = toolName;
99
+ module.exports.MCP_TOOL_DISCOVERED_EVENT = MCP_TOOL_DISCOVERED_EVENT;
package/lib/catalog.js ADDED
@@ -0,0 +1,68 @@
1
+ // The shipped starter catalog: the
2
+ // Google family rides Google's official Workspace remote MCP endpoints
3
+ // (OAuth 2.0 — Google issues no dynamic client registration, so sign-in
4
+ // needs the user's own OAuth client, entered once per box); the Microsoft
5
+ // family rides the ms-365 stdio server ON the box (no official Microsoft
6
+ // remote MCP exists as of 2026-08), which owns its own Microsoft login.
7
+ // Two OAuth families by design — v1 proves the relay against both shapes.
8
+ 'use strict';
9
+
10
+ const CATALOG = [
11
+ {
12
+ key: 'gmail',
13
+ name: 'Gmail',
14
+ transport: 'http',
15
+ url: 'https://gmailmcp.googleapis.com/mcp/v1',
16
+ family: 'google',
17
+ authNote: 'Google OAuth — needs your own OAuth client (Google Cloud console); no dynamic registration.',
18
+ },
19
+ {
20
+ key: 'google_calendar',
21
+ name: 'Google Calendar',
22
+ transport: 'http',
23
+ url: 'https://calendarmcp.googleapis.com/mcp/v1',
24
+ family: 'google',
25
+ authNote: 'Google OAuth — needs your own OAuth client (Google Cloud console); no dynamic registration.',
26
+ },
27
+ {
28
+ key: 'google_drive',
29
+ name: 'Google Drive',
30
+ transport: 'http',
31
+ url: 'https://drivemcp.googleapis.com/mcp/v1',
32
+ family: 'google',
33
+ authNote: 'Google OAuth — needs your own OAuth client (Google Cloud console); no dynamic registration.',
34
+ },
35
+ {
36
+ key: 'outlook_mail',
37
+ name: 'Outlook Mail',
38
+ transport: 'stdio',
39
+ command: 'npx',
40
+ args: ['-y', '@softeria/ms-365-mcp-server', '--enabled-tools', 'mail'],
41
+ family: 'microsoft',
42
+ authNote: 'Runs on the box; signs in to Microsoft with its own login tool on first use.',
43
+ },
44
+ {
45
+ key: 'microsoft_calendar',
46
+ name: 'Microsoft Calendar',
47
+ transport: 'stdio',
48
+ command: 'npx',
49
+ args: ['-y', '@softeria/ms-365-mcp-server', '--enabled-tools', 'calendar'],
50
+ family: 'microsoft',
51
+ authNote: 'Runs on the box; signs in to Microsoft with its own login tool on first use.',
52
+ },
53
+ {
54
+ key: 'onedrive',
55
+ name: 'OneDrive',
56
+ transport: 'stdio',
57
+ command: 'npx',
58
+ args: ['-y', '@softeria/ms-365-mcp-server', '--enabled-tools', 'files'],
59
+ family: 'microsoft',
60
+ authNote: 'Runs on the box; signs in to Microsoft with its own login tool on first use.',
61
+ },
62
+ ];
63
+
64
+ function catalogEntry(key) {
65
+ return CATALOG.find((c) => c.key === key) || null;
66
+ }
67
+
68
+ module.exports = { CATALOG, catalogEntry };
package/lib/engine.js ADDED
@@ -0,0 +1,386 @@
1
+ // The connectors engine: everything both faces
2
+ // share — connect over stdio or streamable HTTP via the official MCP SDK
3
+ // (automatic version negotiation buys both protocol eras), OAuth through the
4
+ // store-backed provider, cached tool lists so a cold connector never blocks
5
+ // anything, and polite declines for mid-run elicitation.
6
+ 'use strict';
7
+
8
+ const {
9
+ Client, StreamableHTTPClientTransport, UnauthorizedError, auth,
10
+ } = require('@modelcontextprotocol/client');
11
+ const { StdioClientTransport } = require('@modelcontextprotocol/client/stdio');
12
+ const { ConnectorsStore } = require('./store');
13
+ const { StoreOAuthProvider } = require('./oauth');
14
+ const { CATALOG, catalogEntry } = require('./catalog');
15
+
16
+ const CLIENT_INFO = { name: 'praxis-connectors', version: '0.1.0' };
17
+ const CONNECT_TIMEOUT_MS = 20_000;
18
+ const CALL_TIMEOUT_MS = 60_000;
19
+ // Refresh ahead of expiry by this much (the host's proactive refresh loop).
20
+ const REFRESH_WINDOW_MS = 10 * 60_000;
21
+
22
+ // Secrets never ride env into spawned connector servers, so every face
23
+ // spawns identically.
24
+ const SECRET_ENV_PATTERN = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
25
+
26
+ function filteredEnv(source = process.env) {
27
+ const env = {};
28
+ for (const [name, value] of Object.entries(source)) {
29
+ if (value === undefined) continue;
30
+ if (SECRET_ENV_PATTERN.test(name)) continue;
31
+ env[name] = value;
32
+ }
33
+ return env;
34
+ }
35
+
36
+ function withTimeout(promise, ms, what) {
37
+ let timer;
38
+ return Promise.race([
39
+ promise,
40
+ new Promise((_, reject) => {
41
+ timer = setTimeout(() => reject(new Error(`${what} timed out after ${Math.round(ms / 1000)}s`)), ms);
42
+ timer.unref?.();
43
+ }),
44
+ ]).finally(() => clearTimeout(timer));
45
+ }
46
+
47
+ /** Plain-language classification for the UI's three states. */
48
+ function classifyError(err) {
49
+ if (err instanceof UnauthorizedError || err?.name === 'UnauthorizedError') {
50
+ return { status: 'needs-signin', detail: 'this connector needs a sign-in' };
51
+ }
52
+ return { status: 'unreachable', detail: String(err?.message || err) };
53
+ }
54
+
55
+ function contentToText(content) {
56
+ return (content || [])
57
+ .map((c) => {
58
+ if (c.type === 'text') return c.text;
59
+ if (c.type === 'image') return '[image content]';
60
+ if (c.type === 'resource_link') return `[resource: ${c.uri}]`;
61
+ return `[${c.type} content]`;
62
+ })
63
+ .filter(Boolean)
64
+ .join('\n');
65
+ }
66
+
67
+ class ConnectorsEngine {
68
+ /**
69
+ * @param {object} [opts]
70
+ * @param {string} [opts.home] store root (default $PRAXIS_CONNECTORS_HOME)
71
+ * @param {(line: string) => void} [opts.onLog]
72
+ * @param {typeof fetch} [opts.fetchFn] test seam for OAuth traffic
73
+ * @param {number} [opts.connectTimeoutMs]
74
+ */
75
+ constructor({ home, onLog, fetchFn, connectTimeoutMs } = {}) {
76
+ this.store = new ConnectorsStore(home);
77
+ this.onLog = onLog || (() => {});
78
+ this.fetchFn = fetchFn;
79
+ this.connectTimeoutMs = connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
80
+ this._live = new Map(); // id → { client, transport }
81
+ }
82
+
83
+ /* ---------------- registry ---------------- */
84
+
85
+ catalog() {
86
+ return CATALOG;
87
+ }
88
+
89
+ /**
90
+ * Disk-only listing (never connects): registry + cached tools + last
91
+ * known status. Status is 'unknown' until something has probed.
92
+ */
93
+ list() {
94
+ return this.store.list().map((entry) => {
95
+ const cache = this.store.readCache(entry.id);
96
+ const authState = this.store.readAuth(entry.id);
97
+ return {
98
+ ...entry,
99
+ status: cache?.status || 'unknown',
100
+ statusDetail: cache?.statusDetail || null,
101
+ toolCount: cache?.tools?.length ?? 0,
102
+ listedAt: cache?.listedAt || null,
103
+ signedIn: Boolean(authState.tokens),
104
+ pendingAuthUrl: authState.pendingAuth?.url || null,
105
+ };
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Add a connector: either from the shipped catalog ({ catalogKey }) or
111
+ * free-form ({ id, name, transport, command/args | url }).
112
+ */
113
+ add(spec = {}) {
114
+ let entry = spec;
115
+ if (spec.catalogKey) {
116
+ const cat = catalogEntry(spec.catalogKey);
117
+ if (!cat) throw new Error(`no catalog entry named ${JSON.stringify(spec.catalogKey)}`);
118
+ entry = {
119
+ id: spec.id || cat.key,
120
+ name: spec.name || cat.name,
121
+ transport: cat.transport,
122
+ command: cat.command,
123
+ args: cat.args,
124
+ url: cat.url,
125
+ catalog: cat.key,
126
+ addedBy: spec.addedBy,
127
+ };
128
+ }
129
+ const added = this.store.upsert(entry);
130
+ this.onLog(`connector ${added.id} added (${added.transport})`);
131
+ return added;
132
+ }
133
+
134
+ remove(id) {
135
+ this._dropLive(id);
136
+ const removed = this.store.remove(id);
137
+ if (removed) this.onLog(`connector ${id} removed`);
138
+ return removed;
139
+ }
140
+
141
+ /* ---------------- connections ---------------- */
142
+
143
+ _requireEntry(id) {
144
+ const entry = this.store.get(id);
145
+ if (!entry) throw new Error(`no connector named ${JSON.stringify(id)}`);
146
+ return entry;
147
+ }
148
+
149
+ _makeTransport(entry) {
150
+ if (entry.transport === 'stdio') {
151
+ return new StdioClientTransport({
152
+ command: entry.command,
153
+ args: entry.args || [],
154
+ env: filteredEnv(),
155
+ stderr: 'ignore',
156
+ });
157
+ }
158
+ // Plain bearer reads only: a probe or tool call must never launch an
159
+ // OAuth flow of its own (a scheduled run has no human). Missing or
160
+ // stale tokens surface as UnauthorizedError → 'needs-signin'; the
161
+ // interactive flow lives in authStart/authFinish, and refresh in
162
+ // refreshDueTokens — all through the full store-backed provider.
163
+ return new StreamableHTTPClientTransport(new URL(entry.url), {
164
+ authProvider: {
165
+ token: async () => this.store.readAuth(entry.id).tokens?.access_token,
166
+ },
167
+ ...(this.fetchFn ? { fetch: this.fetchFn } : {}),
168
+ });
169
+ }
170
+
171
+ _makeClient(entry) {
172
+ const client = new Client(CLIENT_INFO, {
173
+ capabilities: { elicitation: {} },
174
+ versionNegotiation: { mode: 'auto' },
175
+ });
176
+ // A headless run answers elicitation with a well-formed
177
+ // decline and a log line; relaying to the app is a follow-up.
178
+ client.setRequestHandler('elicitation/create', async () => {
179
+ this.onLog(`connector ${entry.id}: declined an input request (no one is watching this run)`);
180
+ return { action: 'decline' };
181
+ });
182
+ return client;
183
+ }
184
+
185
+ async _connect(id) {
186
+ const live = this._live.get(id);
187
+ if (live) return live;
188
+ const entry = this._requireEntry(id);
189
+ const transport = this._makeTransport(entry);
190
+ const client = this._makeClient(entry);
191
+ try {
192
+ await withTimeout(client.connect(transport), this.connectTimeoutMs, `connecting to ${id}`);
193
+ } catch (err) {
194
+ try { await transport.close?.(); } catch { /* already down */ }
195
+ throw err;
196
+ }
197
+ const opened = { client, transport };
198
+ this._live.set(id, opened);
199
+ return opened;
200
+ }
201
+
202
+ _dropLive(id) {
203
+ const live = this._live.get(id);
204
+ if (!live) return;
205
+ this._live.delete(id);
206
+ Promise.resolve(live.client.close?.() ?? live.transport.close?.()).catch(() => {});
207
+ }
208
+
209
+ async close() {
210
+ for (const id of [...this._live.keys()]) this._dropLive(id);
211
+ }
212
+
213
+ /* ---------------- tools ---------------- */
214
+
215
+ /**
216
+ * Connect, list tools, refresh the cache and status. The one operation
217
+ * that talks to the server on purpose.
218
+ */
219
+ async probe(id) {
220
+ this._dropLive(id); // a probe is always a fresh look
221
+ try {
222
+ const { client } = await this._connect(id);
223
+ const { tools } = await withTimeout(client.listTools(), this.connectTimeoutMs, `listing tools on ${id}`);
224
+ const slim = (tools || []).map((t) => ({
225
+ name: t.name,
226
+ description: t.description || '',
227
+ inputSchema: t.inputSchema || { type: 'object' },
228
+ }));
229
+ this.store.writeCache(id, { tools: slim, status: 'connected' });
230
+ return { status: 'connected', tools: slim };
231
+ } catch (err) {
232
+ const { status, detail } = classifyError(err);
233
+ this.store.writeStatus(id, status, detail);
234
+ return { status, detail, tools: this.store.readCache(id)?.tools || [] };
235
+ }
236
+ }
237
+
238
+ /** Cached tools only — never connects. */
239
+ cachedTools(id) {
240
+ return this.store.readCache(id)?.tools || [];
241
+ }
242
+
243
+ async callTool(id, toolName, args = {}, { timeoutMs = CALL_TIMEOUT_MS } = {}) {
244
+ try {
245
+ const { client } = await this._connect(id);
246
+ const result = await client.callTool(
247
+ { name: toolName, arguments: args },
248
+ { timeout: timeoutMs },
249
+ );
250
+ return {
251
+ text: contentToText(result.content) || '(no output)',
252
+ isError: Boolean(result.isError),
253
+ };
254
+ } catch (err) {
255
+ // A dead child/socket poisons the pooled connection — drop it so the
256
+ // next call reconnects instead of failing forever.
257
+ this._dropLive(id);
258
+ const { status, detail } = classifyError(err);
259
+ this.store.writeStatus(id, status, detail);
260
+ const reason = status === 'needs-signin'
261
+ ? `connector ${id} needs a sign-in before its tools can run`
262
+ : `connector ${id} is unreachable: ${detail}`;
263
+ return { text: reason, isError: true };
264
+ }
265
+ }
266
+
267
+ /* ---------------- sign-in (the relay) ---------------- */
268
+
269
+ /**
270
+ * Start the OAuth flow box-side. Returns { authorizationUrl } for the app
271
+ * to hand to a human, or { authorized: true } when a refresh (or an AS
272
+ * with no interactive step) finished without one.
273
+ */
274
+ async authStart(id, { redirectUrl, scope } = {}) {
275
+ const entry = this._requireEntry(id);
276
+ if (entry.transport !== 'http') {
277
+ throw new Error(`connector ${id} runs on this machine and manages its own sign-in — no relay needed`);
278
+ }
279
+ const provider = new StoreOAuthProvider(this.store, id, { redirectUrl });
280
+ const result = await auth(provider, {
281
+ serverUrl: entry.url,
282
+ ...(scope ? { scope } : {}),
283
+ ...(this.fetchFn ? { fetchFn: this.fetchFn } : {}),
284
+ });
285
+ if (result === 'REDIRECT') {
286
+ return { authorizationUrl: provider.capturedAuthorizationUrl };
287
+ }
288
+ await this.probe(id);
289
+ return { authorized: true };
290
+ }
291
+
292
+ /**
293
+ * Finish the flow with the code — however it arrived (serve's callback
294
+ * route or the paste-back field). Tokens are exchanged and stored
295
+ * box-side; on success the tool cache warms.
296
+ */
297
+ async authFinish(id, { code, iss } = {}) {
298
+ const entry = this._requireEntry(id);
299
+ if (!code || !String(code).trim()) throw new Error('an authorization code is required');
300
+ const provider = new StoreOAuthProvider(this.store, id);
301
+ const result = await auth(provider, {
302
+ serverUrl: entry.url,
303
+ authorizationCode: String(code).trim(),
304
+ ...(iss ? { iss } : {}),
305
+ ...(this.fetchFn ? { fetchFn: this.fetchFn } : {}),
306
+ });
307
+ if (result !== 'AUTHORIZED') {
308
+ throw new Error('the sign-in did not complete — try starting it again');
309
+ }
310
+ this.onLog(`connector ${id}: signed in`);
311
+ return this.probe(id);
312
+ }
313
+
314
+ /** The callback route's router: which connector does this state belong to? */
315
+ findPendingByState(state) {
316
+ if (!state) return null;
317
+ for (const entry of this.store.list()) {
318
+ const pending = this.store.readAuth(entry.id).pendingAuth;
319
+ if (pending?.state && pending.state === state) return entry.id;
320
+ }
321
+ return null;
322
+ }
323
+
324
+ /**
325
+ * Pre-seed OAuth client credentials for authorization servers that issue
326
+ * no dynamic registration (the Google family): the user pastes their own
327
+ * client id/secret once per box; it lands in auth/<id>.json like every
328
+ * other credential.
329
+ */
330
+ setClientCredentials(id, { clientId, clientSecret } = {}) {
331
+ this._requireEntry(id);
332
+ if (!clientId) throw new Error('a client id is required');
333
+ this.store.patchAuth(id, {
334
+ clientInfo: {
335
+ client_id: String(clientId),
336
+ ...(clientSecret ? { client_secret: String(clientSecret) } : {}),
337
+ },
338
+ });
339
+ }
340
+
341
+ /* ---------------- proactive refresh ---------------- */
342
+
343
+ /**
344
+ * Refresh every signed-in http connector whose token expires within the
345
+ * window. Serve calls this on a timer — it is the long-lived process, so
346
+ * routes stay warm with the Mac off.
347
+ */
348
+ async refreshDueTokens({ windowMs = REFRESH_WINDOW_MS, now = Date.now() } = {}) {
349
+ const report = [];
350
+ for (const entry of this.store.list()) {
351
+ if (entry.transport !== 'http') continue;
352
+ const authState = this.store.readAuth(entry.id);
353
+ const { tokens, tokensSavedAt } = authState;
354
+ if (!tokens?.refresh_token || !tokens.expires_in || !tokensSavedAt) continue;
355
+ const expiresAt = new Date(tokensSavedAt).getTime() + tokens.expires_in * 1000;
356
+ if (expiresAt - now > windowMs) continue;
357
+ try {
358
+ const provider = new StoreOAuthProvider(this.store, entry.id);
359
+ const result = await auth(provider, {
360
+ serverUrl: entry.url,
361
+ ...(this.fetchFn ? { fetchFn: this.fetchFn } : {}),
362
+ });
363
+ if (result === 'AUTHORIZED') {
364
+ report.push({ id: entry.id, refreshed: true });
365
+ this.onLog(`connector ${entry.id}: token refreshed ahead of expiry`);
366
+ } else {
367
+ // The AS rejected the refresh token and the SDK fell back to a
368
+ // fresh interactive flow — only a human can finish that.
369
+ report.push({ id: entry.id, refreshed: false });
370
+ this.store.writeStatus(entry.id, 'needs-signin', 'the sign-in expired and could not be renewed');
371
+ this.onLog(`connector ${entry.id}: sign-in expired — needs a fresh sign-in`);
372
+ }
373
+ } catch (err) {
374
+ report.push({ id: entry.id, refreshed: false, error: String(err?.message || err) });
375
+ this.store.writeStatus(entry.id, 'needs-signin', 'the sign-in expired and could not be renewed');
376
+ this.onLog(`connector ${entry.id}: token refresh failed (${String(err?.message || err)})`);
377
+ }
378
+ }
379
+ return report;
380
+ }
381
+ }
382
+
383
+ module.exports = {
384
+ ConnectorsEngine, filteredEnv, contentToText, classifyError,
385
+ SECRET_ENV_PATTERN, REFRESH_WINDOW_MS,
386
+ };
package/lib/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // @crossgen-ai/praxis-connectors — the engine face.
2
+ // A host process links this as a library; the harness loads
3
+ // ./extension as a pi extension; both share one box-local store.
4
+ 'use strict';
5
+
6
+ const { ConnectorsEngine, filteredEnv, contentToText, SECRET_ENV_PATTERN, REFRESH_WINDOW_MS } = require('./engine');
7
+ const { ConnectorsStore, resolveHome, validateId, ID_PATTERN } = require('./store');
8
+ const { StoreOAuthProvider } = require('./oauth');
9
+ const { CATALOG, catalogEntry } = require('./catalog');
10
+
11
+ module.exports = {
12
+ ConnectorsEngine,
13
+ ConnectorsStore,
14
+ StoreOAuthProvider,
15
+ CATALOG,
16
+ catalogEntry,
17
+ resolveHome,
18
+ validateId,
19
+ ID_PATTERN,
20
+ filteredEnv,
21
+ contentToText,
22
+ SECRET_ENV_PATTERN,
23
+ REFRESH_WINDOW_MS,
24
+ };