@zackbart/connecta 0.24.4 → 0.26.0
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/AGENTS.md +43 -12
- package/CHANGELOG.md +524 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/advertised-schema.js +53 -0
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +76 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +449 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +194 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +652 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +152 -106
- package/dist/catalog.js +297 -38
- package/dist/concurrency.js +24 -23
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +330 -240
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +745 -282
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +145 -63
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +255 -212
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +72 -7
- package/dist/index.js +281 -26
- package/dist/invocation.js +371 -286
- package/dist/meta-tools.js +213 -94
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +103 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +128 -3
- package/dist/program-source.js +321 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +43 -7
- package/dist/registry.js +434 -288
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +74 -154
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +196 -163
- package/dist/routes/mcp.js +228 -112
- package/dist/routes/oauth-management.js +73 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +83 -0
- package/dist/routes/shared.d.ts +25 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +211 -0
- package/dist/routes/ui.js +76 -35
- package/dist/run-journal.js +347 -0
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +137 -89
- package/dist/skills.js +28 -9
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +39 -43
- package/dist/tool-safety.d.ts +27 -1
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +45 -7
- package/dist/ui.d.ts +25 -4
- package/dist/ui.js +77 -167
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +367 -16
- package/documentation/auth.md +163 -2
- package/documentation/code-mode.md +274 -56
- package/documentation/meta-tools.md +200 -44
- package/ethos.md +46 -47
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +224 -14
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +42 -7
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +21 -5
- package/package.json +16 -4
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
package/dist/meta-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { advertisedSchema } from "./advertised-schema.js";
|
|
2
3
|
import { boundedDiscoveryText, CatalogService, DEFAULT_SEARCH_LIMIT, DiscoveryPolicyError, groupedSearchResult, MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, } from "./catalog-service.js";
|
|
3
4
|
import { resolveDiscoveryConcurrency } from "./concurrency.js";
|
|
4
5
|
import { boundedEchoText, msg } from "./errors.js";
|
|
@@ -7,6 +8,7 @@ import { InvocationService, } from "./invocation.js";
|
|
|
7
8
|
import { isValidMaxResultBytes, MIN_MAX_RESULT_BYTES, resolveMaxResultBytes, } from "./registry.js";
|
|
8
9
|
import { hasConnectorGuides, listSkills, resolveSkill, } from "./skills.js";
|
|
9
10
|
import { DEFAULT_PROBE_TIMEOUT_MS, normalizeTimeoutMs, } from "./timeout.js";
|
|
11
|
+
import { isExplicitlyReadOnly } from "./tool-safety.js";
|
|
10
12
|
export { MAX_DESCRIBE_ADDRESSES, MAX_DISCOVERY_RESULT_BYTES, MAX_SEARCH_LIMIT, };
|
|
11
13
|
const RESULT_TTL_SECONDS = 900;
|
|
12
14
|
const enc = new TextEncoder();
|
|
@@ -101,13 +103,20 @@ export function alignEndToCharBoundary(bytes, offset, end, total) {
|
|
|
101
103
|
}
|
|
102
104
|
/** Prefix of the chunked paging envelope; `v1`'s single inline key still reads. */
|
|
103
105
|
const RESULT_ENVELOPE_V2 = "connecta-result-v2:";
|
|
106
|
+
/**
|
|
107
|
+
* The current envelope: v2's header plus the inline cap of the call that
|
|
108
|
+
* stashed it, `<total>:<chunk>:<cap>:`, so get_result can clamp a page to the
|
|
109
|
+
* connector's own cap without the stash carrying connector identity.
|
|
110
|
+
*/
|
|
111
|
+
const RESULT_ENVELOPE_V3 = "connecta-result-v3:";
|
|
112
|
+
const RESULT_ENVELOPE_V3_HEADER = new RegExp(`^${RESULT_ENVELOPE_V3}(\\d+):(\\d+):(\\d+):`);
|
|
104
113
|
/** `<total bytes>:<bytes per chunk>:` follow the prefix, then chunk 0's base64. */
|
|
105
114
|
const RESULT_ENVELOPE_V2_HEADER = new RegExp(`^${RESULT_ENVELOPE_V2}(\\d+):(\\d+):`);
|
|
106
115
|
/**
|
|
107
116
|
* Smallest chunk of result text stored under one key. A multiple of three so
|
|
108
117
|
* every chunk's base64 stands alone and a byte offset inside it lands on a
|
|
109
|
-
* whole quad, and
|
|
110
|
-
* reads
|
|
118
|
+
* whole quad, and about twice the 24,000-byte default page so a default page
|
|
119
|
+
* reads one or two chunks rather than dozens.
|
|
111
120
|
*/
|
|
112
121
|
const RESULT_CHUNK_BYTES = 49_152;
|
|
113
122
|
/**
|
|
@@ -134,8 +143,44 @@ function base64Of(bytes) {
|
|
|
134
143
|
}
|
|
135
144
|
return out;
|
|
136
145
|
}
|
|
137
|
-
/**
|
|
138
|
-
|
|
146
|
+
/** Opens a write's notice, ahead of any paging instruction. */
|
|
147
|
+
const WRITE_ALREADY_RAN = "This write already ran: do not call it again to see its result.";
|
|
148
|
+
/**
|
|
149
|
+
* Offers a read the route meta-tools.md prefers for finding something in it:
|
|
150
|
+
* repeat the call inside a program and reduce it there. Paging comes second,
|
|
151
|
+
* for reading the whole thing. Led by paging alone, the eval's weakest model
|
|
152
|
+
* read a 185 KB log in eight pages, skipped the range holding the answer, and
|
|
153
|
+
* reported the decoy near the top instead. A write never gets this: repeating
|
|
154
|
+
* it is the one thing its notice forbids.
|
|
155
|
+
*/
|
|
156
|
+
const READ_REDUCE_FIRST = "To find something specific, repeat this read inside execute_code with connecta.call and filter or search the result there, returning only what matters";
|
|
157
|
+
function pagingHint(results, totalBytes, preview) {
|
|
158
|
+
if (!results.write) {
|
|
159
|
+
const [shown, full] = preview.kind === "prefix"
|
|
160
|
+
? [`Bytes 0-${preview.bytes} of ${totalBytes} follow.`, "to read it in full, page the rest"]
|
|
161
|
+
: preview.kind === "text-of-envelope"
|
|
162
|
+
? ["Its text follows.", `to read the full ${totalBytes}-byte content array as JSON, page it`]
|
|
163
|
+
: [`The result is ${totalBytes} bytes.`, "to read it in full, page it"];
|
|
164
|
+
return `${shown} ${READ_REDUCE_FIRST}; ${full} with get_result using nextAction.`;
|
|
165
|
+
}
|
|
166
|
+
const body = preview.kind === "prefix"
|
|
167
|
+
? `Bytes 0-${preview.bytes} of ${totalBytes} follow; page the rest with get_result using nextAction.`
|
|
168
|
+
: preview.kind === "text-of-envelope"
|
|
169
|
+
? `Its text follows; page the full ${totalBytes}-byte content array as JSON with get_result using nextAction.`
|
|
170
|
+
: `Page all ${totalBytes} bytes with get_result using nextAction.`;
|
|
171
|
+
return `${WRITE_ALREADY_RAN} ${body}`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Stash a completed result and describe it with a notice that leads whatever
|
|
175
|
+
* the caller returns, or a notice without a paging route when stashing fails.
|
|
176
|
+
*
|
|
177
|
+
* The notice goes first because clients cut oversized results from the end:
|
|
178
|
+
* Claude Code keeps the first 2,000 characters of a result it spills to disk,
|
|
179
|
+
* and a notice at the tail never reached the agent (see
|
|
180
|
+
* documentation/meta-tools.md#result-representation). It is one compact JSON
|
|
181
|
+
* line with no raw newline, so the preview starts after the first `\n`.
|
|
182
|
+
*/
|
|
183
|
+
async function stashResult(bytes, results, preview) {
|
|
139
184
|
const totalBytes = bytes.length;
|
|
140
185
|
const id = crypto.randomUUID();
|
|
141
186
|
try {
|
|
@@ -144,7 +189,7 @@ async function stashResult(bytes, results) {
|
|
|
144
189
|
// the whole result (issue #540). Chunk 0 carries the header; the get_result
|
|
145
190
|
// reader below maps a byte offset back to chunk index and base64 quad.
|
|
146
191
|
const chunkBytes = resultChunkBytes(totalBytes);
|
|
147
|
-
const chunks = [`${
|
|
192
|
+
const chunks = [`${RESULT_ENVELOPE_V3}${totalBytes}:${chunkBytes}:${results.cap}:`];
|
|
148
193
|
for (let offset = 0; offset < bytes.length; offset += chunkBytes) {
|
|
149
194
|
const chunk = base64Of(bytes.subarray(offset, offset + chunkBytes));
|
|
150
195
|
if (offset === 0)
|
|
@@ -168,25 +213,37 @@ async function stashResult(bytes, results) {
|
|
|
168
213
|
return {
|
|
169
214
|
truncated: true,
|
|
170
215
|
totalBytes,
|
|
171
|
-
hint:
|
|
216
|
+
hint: results.write
|
|
217
|
+
? `Paging is unavailable. ${WRITE_ALREADY_RAN}`
|
|
218
|
+
: "Paging is unavailable. Use execute_code to reduce read-only results before returning them.",
|
|
172
219
|
};
|
|
173
220
|
}
|
|
174
221
|
return {
|
|
175
222
|
truncated: true,
|
|
176
223
|
resultId: id,
|
|
177
224
|
totalBytes,
|
|
178
|
-
hint:
|
|
225
|
+
hint: pagingHint(results, totalBytes, preview),
|
|
179
226
|
nextAction: {
|
|
180
227
|
tool: "get_result",
|
|
181
|
-
arguments: { id, offset: 0 },
|
|
228
|
+
arguments: { id, offset: preview.kind === "prefix" ? preview.bytes : 0 },
|
|
182
229
|
},
|
|
183
230
|
};
|
|
184
231
|
}
|
|
232
|
+
/** The first `cap` bytes of `bytes`, ending on a character boundary. */
|
|
233
|
+
function headOf(bytes, cap) {
|
|
234
|
+
return bytes.subarray(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length));
|
|
235
|
+
}
|
|
236
|
+
/** One text block: the notice line, then the preview. */
|
|
237
|
+
function noticeFirst(notice, preview) {
|
|
238
|
+
return { content: [{ type: "text", text: `${JSON.stringify(notice)}\n${preview}` }] };
|
|
239
|
+
}
|
|
185
240
|
/**
|
|
186
241
|
* Return `text` as a single content block; if it exceeds `cap` bytes, stash the
|
|
187
|
-
* full text and return
|
|
188
|
-
*
|
|
189
|
-
*
|
|
242
|
+
* full text and return a JSON truncation notice pointing at get_result,
|
|
243
|
+
* followed by the first `cap` bytes. The preview is a byte prefix of what
|
|
244
|
+
* pages, so the notice's next action continues where it stops. `bytes` is
|
|
245
|
+
* `text` already encoded, so a caller that had to measure it to make this
|
|
246
|
+
* decision doesn't encode it twice.
|
|
190
247
|
*/
|
|
191
248
|
async function guardEncoded(text, bytes, results, cap) {
|
|
192
249
|
if (bytes.length <= cap) {
|
|
@@ -195,14 +252,12 @@ async function guardEncoded(text, bytes, results, cap) {
|
|
|
195
252
|
truncated: false,
|
|
196
253
|
};
|
|
197
254
|
}
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
truncated: true,
|
|
205
|
-
};
|
|
255
|
+
const head = headOf(bytes, cap);
|
|
256
|
+
const notice = await stashResult(bytes, results, {
|
|
257
|
+
kind: "prefix",
|
|
258
|
+
bytes: head.length,
|
|
259
|
+
});
|
|
260
|
+
return { result: noticeFirst(notice, dec.decode(head)), truncated: true };
|
|
206
261
|
}
|
|
207
262
|
/** {@link guardEncoded} over a string that has not been measured yet. */
|
|
208
263
|
async function guardText(text, results, cap) {
|
|
@@ -220,34 +275,62 @@ async function guardValue(value, results, cap) {
|
|
|
220
275
|
const bytes = enc.encode(text);
|
|
221
276
|
if (bytes.length <= cap)
|
|
222
277
|
return { result: value, truncated: false };
|
|
223
|
-
const notice = await stashResult(bytes, results);
|
|
278
|
+
const notice = await stashResult(bytes, results, { kind: "none" });
|
|
224
279
|
return {
|
|
225
280
|
result: notice.resultId ? notice : {
|
|
226
281
|
...notice,
|
|
227
|
-
preview:
|
|
282
|
+
preview: escapedHeadOf(bytes, cap),
|
|
228
283
|
},
|
|
229
284
|
truncated: true,
|
|
230
285
|
};
|
|
231
286
|
}
|
|
232
287
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
288
|
+
* The longest head of `bytes` whose JSON string form fits `cap` bytes. Value
|
|
289
|
+
* mode carries an unpageable preview inside its JSON envelope, where escaping
|
|
290
|
+
* could double a quote-heavy head cut at `cap` bytes; a response that only
|
|
291
|
+
* exists because paging failed must not be the one that breaks the cap.
|
|
292
|
+
*/
|
|
293
|
+
function escapedHeadOf(bytes, cap) {
|
|
294
|
+
let limit = cap;
|
|
295
|
+
for (;;) {
|
|
296
|
+
const head = limit === 0 ? "" : dec.decode(headOf(bytes, limit));
|
|
297
|
+
const escaped = enc.encode(JSON.stringify(head)).length;
|
|
298
|
+
if (escaped <= cap || limit === 0)
|
|
299
|
+
return head;
|
|
300
|
+
limit = Math.max(0, Math.min(limit - 1, Math.floor((limit * cap) / escaped)));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Bound a downstream MCP `content` array by `cap`.
|
|
236
305
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
306
|
+
* A lone text block is measured, stashed, previewed, and paged as its text:
|
|
307
|
+
* its envelope adds nothing but a wrapper and a second layer of JSON escaping,
|
|
308
|
+
* which made a JSON payload's preview unreadable and inflated every page.
|
|
309
|
+
*
|
|
310
|
+
* Anything else is measured as the serialized envelope — the same string that
|
|
311
|
+
* gets stashed and paged, and the one that counts every block rather than only
|
|
312
|
+
* the text ones. Both halves matter (issue #43). Measuring only text blocks
|
|
313
|
+
* meant an oversized all-image result scored zero bytes and was returned inline
|
|
314
|
+
* unbounded, with no `resultId` to page from; and measuring one string while
|
|
315
|
+
* truncating another left `totalBytes` describing something the cap was never
|
|
241
316
|
* compared against.
|
|
242
317
|
*
|
|
243
|
-
* Over the cap, what a client gets depends on whether a
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* envelope is stashed and pages through `get_result`.
|
|
318
|
+
* Over the cap, what a client gets depends on whether a preview is usable.
|
|
319
|
+
* Several text blocks preview as their text joined by newlines, while
|
|
320
|
+
* get_result pages the envelope from offset 0, where the block boundaries
|
|
321
|
+
* live. An envelope carrying non-text blocks is replaced by the notice alone:
|
|
322
|
+
* the head of a half-written base64 image is of no use to anyone. Either way
|
|
323
|
+
* the full envelope is stashed and pages through `get_result`.
|
|
249
324
|
*/
|
|
250
325
|
async function guardContent(content, results, cap) {
|
|
326
|
+
const only = content.length === 1 ? content[0] : undefined;
|
|
327
|
+
if (only?.type === "text" && typeof only.text === "string") {
|
|
328
|
+
const bytes = enc.encode(only.text);
|
|
329
|
+
// Under the cap the block passes through untouched, annotations included.
|
|
330
|
+
if (bytes.length <= cap)
|
|
331
|
+
return { result: { content }, truncated: false };
|
|
332
|
+
return guardEncoded(only.text, bytes, results, cap);
|
|
333
|
+
}
|
|
251
334
|
let text;
|
|
252
335
|
try {
|
|
253
336
|
text = JSON.stringify(content);
|
|
@@ -266,9 +349,14 @@ async function guardContent(content, results, cap) {
|
|
|
266
349
|
return { result: { content }, truncated: false };
|
|
267
350
|
}
|
|
268
351
|
if (content.every((b) => b.type === "text")) {
|
|
269
|
-
|
|
352
|
+
const joined = enc.encode(content.map((b) => b.text).join("\n"));
|
|
353
|
+
const notice = await stashResult(bytes, results, { kind: "text-of-envelope" });
|
|
354
|
+
return {
|
|
355
|
+
result: noticeFirst(notice, dec.decode(headOf(joined, cap))),
|
|
356
|
+
truncated: true,
|
|
357
|
+
};
|
|
270
358
|
}
|
|
271
|
-
const notice = await stashResult(bytes, results);
|
|
359
|
+
const notice = await stashResult(bytes, results, { kind: "none" });
|
|
272
360
|
return {
|
|
273
361
|
result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
|
|
274
362
|
truncated: true,
|
|
@@ -311,6 +399,8 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
311
399
|
probeTimeoutMs,
|
|
312
400
|
concurrency: discoveryConcurrency,
|
|
313
401
|
defer: opts.defer,
|
|
402
|
+
requestSignal: opts.requestSignal,
|
|
403
|
+
approval: opts.approval,
|
|
314
404
|
// searchRoute keeps its top-level default. In-program callers use a
|
|
315
405
|
// separate CatalogService configured for connecta.search.
|
|
316
406
|
});
|
|
@@ -329,13 +419,6 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
329
419
|
: {}),
|
|
330
420
|
unwrapResult: call.resultMode === "value",
|
|
331
421
|
processResult: async (result, resolved) => {
|
|
332
|
-
const results = {
|
|
333
|
-
set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
|
|
334
|
-
warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
|
|
335
|
-
connector: resolved.connector.id,
|
|
336
|
-
tool: resolved.toolName,
|
|
337
|
-
}),
|
|
338
|
-
};
|
|
339
422
|
// Result-size cap for THIS call: the connector's own override wins,
|
|
340
423
|
// then the deployment-wide value, then the built-in default (already
|
|
341
424
|
// folded into `globalCap`). Resolved per call so one request can
|
|
@@ -343,6 +426,15 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
343
426
|
// override the registry already warned about at startup is dropped
|
|
344
427
|
// here, so the connector simply inherits `globalCap`.
|
|
345
428
|
const cap = resolveMaxResultBytes(resolved.connector.maxResultBytes, globalCap);
|
|
429
|
+
const results = {
|
|
430
|
+
write: !isExplicitlyReadOnly(resolved.definition),
|
|
431
|
+
cap,
|
|
432
|
+
set: (key, value, ttlSeconds) => registry.stashResult(key, value, ttlSeconds),
|
|
433
|
+
warn: () => registry.contextFor(resolved.connector.id, baseUrl, requestScope).logger.warn("[connecta] result paging unavailable", {
|
|
434
|
+
connector: resolved.connector.id,
|
|
435
|
+
tool: resolved.toolName,
|
|
436
|
+
}),
|
|
437
|
+
};
|
|
346
438
|
const processed = (toolResult, truncated, value) => ({
|
|
347
439
|
toolResult,
|
|
348
440
|
...value,
|
|
@@ -379,6 +471,8 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
379
471
|
// Sanitized `unavailable` diagnostics ride the structured shape too;
|
|
380
472
|
// the plain-text path would drop them (#539).
|
|
381
473
|
outcome.error.details !== undefined ||
|
|
474
|
+
// So does a conflict's `current`: where things stand is the retry.
|
|
475
|
+
outcome.error.current !== undefined ||
|
|
382
476
|
[
|
|
383
477
|
"auth_required",
|
|
384
478
|
"invalid_args",
|
|
@@ -518,15 +612,24 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
518
612
|
return errorResult(`Unknown or expired result id "${boundedEchoText(args.id)}"`);
|
|
519
613
|
}
|
|
520
614
|
const requestedOffset = args.offset ?? 0;
|
|
521
|
-
const maxBytes = args.maxBytes ?? globalCap;
|
|
522
615
|
// Read and decode only the chunks this page covers, plus a few bytes of
|
|
523
616
|
// UTF-8 boundary lookaround. Pre-upgrade entries stay readable for their
|
|
524
|
-
// short TTL:
|
|
525
|
-
//
|
|
526
|
-
// that still pays one full encode
|
|
527
|
-
|
|
617
|
+
// short TTL: v2 is this format without the recorded cap, v1 inlined the
|
|
618
|
+
// whole envelope under one key, which is v2 with a single chunk as wide
|
|
619
|
+
// as the result, and raw text before that still pays one full encode
|
|
620
|
+
// per page.
|
|
621
|
+
const current = RESULT_ENVELOPE_V3_HEADER.exec(stored.slice(0, 96));
|
|
622
|
+
const chunked = current ?? RESULT_ENVELOPE_V2_HEADER.exec(stored.slice(0, 80));
|
|
528
623
|
const inline = chunked ? null : /^connecta-result-v1:(\d+):/.exec(stored.slice(0, 64));
|
|
529
624
|
const header = chunked ?? inline;
|
|
625
|
+
// A page never exceeds the inline cap of the call that stashed it — the
|
|
626
|
+
// connector's override when it had one — whatever maxBytes asks for.
|
|
627
|
+
// Clients cut an oversized page exactly as they cut the original result,
|
|
628
|
+
// so honouring a larger request would hand the agent an error in place
|
|
629
|
+
// of its data. Entries from before the cap was recorded use the
|
|
630
|
+
// deployment-wide one.
|
|
631
|
+
const cap = current ? Number(current[3]) : globalCap;
|
|
632
|
+
const maxBytes = Math.min(args.maxBytes ?? cap, cap);
|
|
530
633
|
let bytes;
|
|
531
634
|
let total;
|
|
532
635
|
let start = 0;
|
|
@@ -569,23 +672,29 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
569
672
|
// the boundary at or before it, and it is what the response reports back
|
|
570
673
|
// as `offset` (issue #38).
|
|
571
674
|
const offset = start + alignStartToCharBoundary(bytes, requestedOffset - start);
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
// Both sides are validated by now — the argument above, `globalCap` at
|
|
575
|
-
// intake — so `offset + maxBytes` always reaches past `offset`.
|
|
675
|
+
// Both sides of `maxBytes` are validated by now — the argument above, the
|
|
676
|
+
// cap at intake — so `offset + maxBytes` always reaches past `offset`.
|
|
576
677
|
// Align the slice end to a codepoint boundary so a multi-byte char is
|
|
577
678
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
578
679
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
579
680
|
// next call and paging reassembles the original byte-for-byte.
|
|
580
681
|
const end = start + alignEndToCharBoundary(bytes, offset - start, offset - start + maxBytes, total - start);
|
|
581
682
|
const slice = dec.decode(bytes.subarray(offset - start, end - start));
|
|
582
|
-
const
|
|
583
|
-
|
|
683
|
+
const hasMore = end < total;
|
|
684
|
+
// The same notice-first shape as a truncated call: one line of header,
|
|
685
|
+
// then the page as raw text. A JSON `text` field escaped every quote and
|
|
686
|
+
// newline in the page — a second layer over JSON payloads — which
|
|
687
|
+
// inflated pages past the size clients accept and made them hard to read.
|
|
688
|
+
return noticeFirst({
|
|
689
|
+
resultId: args.id,
|
|
584
690
|
offset,
|
|
585
|
-
|
|
691
|
+
bytes: Math.max(0, end - offset),
|
|
586
692
|
totalBytes: total,
|
|
587
|
-
|
|
588
|
-
|
|
693
|
+
hasMore,
|
|
694
|
+
...(hasMore
|
|
695
|
+
? { nextAction: { tool: "get_result", arguments: { id: args.id, offset: end } } }
|
|
696
|
+
: {}),
|
|
697
|
+
}, slice);
|
|
589
698
|
},
|
|
590
699
|
async authorizeConnector(args) {
|
|
591
700
|
const connector = registry.getConnector(args.connector);
|
|
@@ -674,10 +783,10 @@ export function createMetaTools(registry, baseUrl, opts = {}) {
|
|
|
674
783
|
},
|
|
675
784
|
};
|
|
676
785
|
}
|
|
677
|
-
const SEARCH_DESC = `Use top-level search for catalog inspection or approval-required work before call_destructive_tool. Unknown-address read-only work belongs in one execute_code program that searches, calls, and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; the default limit is ${DEFAULT_SEARCH_LIMIT}. Set connector when known. safety="readOnly" finds
|
|
786
|
+
const SEARCH_DESC = `Use top-level search for catalog inspection or approval-required work before call_destructive_tool. Unknown-address read-only work belongs in one execute_code program that searches, calls, and returns the answer. Use 2–4 action/object terms and includeSchemas="compact"; the default limit is ${DEFAULT_SEARCH_LIMIT}. Set connector when known. safety="readOnly" finds tools that run unasked; "approvalRequired" finds the fail-closed complement. These filters grant no authority. Empty query browses.`;
|
|
678
787
|
const CALL_DESC = 'Call one known-address tool explicitly annotated readOnlyHint: true. Use execute_code for unknown-address, multiple, dependent, or reduced read-only work. Unannotated or write-capable tools fail closed to call_destructive_tool. A truncated result carries a get_result action.';
|
|
679
788
|
const CALL_DESTRUCTIVE_DESC = "Call any tool not explicitly annotated readOnlyHint: true. Include a short reason for the human reviewer after checking the schema and consequences. The reason grants no authority and is not sent downstream.";
|
|
680
|
-
const GET_RESULT_DESC = "Page a truncated direct-call result by id and byte offset. A program result is never paged; reduce it inside execute_code. Returns
|
|
789
|
+
const GET_RESULT_DESC = "Page a truncated direct-call result by id and byte offset. A program result is never paged; reduce it inside execute_code. Returns a one-line JSON header (offset, bytes, totalBytes, hasMore, nextAction) and then the page as raw text. maxBytes is an upper bound, clamped to the result's inline cap.";
|
|
681
790
|
const AUTHORIZE_DESC = "Use after auth_required. Returns an OAuth or operator-credential handoff, or reports required deployment configuration. force=true restarts OAuth only; this tool never accepts credentials.";
|
|
682
791
|
const SKILLS_DESC = 'List or fetch on-demand guidance. Fetch usage only when the always-loaded instructions are insufficient or a program needs repair.';
|
|
683
792
|
/**
|
|
@@ -724,9 +833,46 @@ const CALL_INPUT_SCHEMA = {
|
|
|
724
833
|
timeoutMs: z.number().int().positive().optional(),
|
|
725
834
|
diagnostics: z.boolean().optional(),
|
|
726
835
|
};
|
|
836
|
+
// The six input schemas, built once at module scope so their JSON Schema is
|
|
837
|
+
// derived once per process (see advertisedSchema). Nothing here may read
|
|
838
|
+
// deployment configuration or the request's identity: anything that varies
|
|
839
|
+
// belongs in the per-registration description instead.
|
|
840
|
+
const SKILLS_INPUT = advertisedSchema(z.object({ name: z.string().optional() }));
|
|
841
|
+
const SEARCH_INPUT = advertisedSchema(z.object({
|
|
842
|
+
query: z.string().optional(),
|
|
843
|
+
connector: z.string().optional(),
|
|
844
|
+
safety: z.enum(["readOnly", "approvalRequired", "all"]).optional(),
|
|
845
|
+
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
|
|
846
|
+
offset: z.number().int().nonnegative().optional(),
|
|
847
|
+
fullDescriptions: z.boolean().optional(),
|
|
848
|
+
includeSchemas: z.enum(["compact", "json", "typescript"]).optional(),
|
|
849
|
+
}));
|
|
850
|
+
const CALL_INPUT = advertisedSchema(z.strictObject(CALL_INPUT_SCHEMA));
|
|
851
|
+
const CALL_DESTRUCTIVE_INPUT = advertisedSchema(z.strictObject({
|
|
852
|
+
...CALL_INPUT_SCHEMA,
|
|
853
|
+
// Bounded above, but with no lower bound: a model that sends `""` or
|
|
854
|
+
// whitespace has written no reason, and failing an entire consequential
|
|
855
|
+
// call over a cosmetic field the host merely displays is the wrong
|
|
856
|
+
// trade. It is normalized to absent in the handler instead.
|
|
857
|
+
reason: z.string().max(500).optional(),
|
|
858
|
+
}));
|
|
859
|
+
const AUTHORIZE_INPUT = advertisedSchema(z.object({
|
|
860
|
+
connector: z.string(),
|
|
861
|
+
force: z.boolean().optional(),
|
|
862
|
+
}));
|
|
863
|
+
const GET_RESULT_INPUT = advertisedSchema(z.object({
|
|
864
|
+
id: z.string(),
|
|
865
|
+
// Both bounds are the shared rules (isValidResultOffset,
|
|
866
|
+
// isValidMaxResultBytes) expressed for the wire: spelling them against
|
|
867
|
+
// the same constants keeps the schema from drifting away from the
|
|
868
|
+
// in-handler checks if either floor ever moves.
|
|
869
|
+
offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
|
|
870
|
+
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
871
|
+
}));
|
|
727
872
|
/**
|
|
728
873
|
* Register the six explicit meta-tools onto an McpServer instance.
|
|
729
|
-
* `registerExecuteTool` adds the seventh, `execute_code
|
|
874
|
+
* `registerExecuteTool` adds the seventh, `execute_code`, and
|
|
875
|
+
* `registerResumeTool` the eighth, `resume_execution`. Broad discovery and
|
|
730
876
|
* multi-call work uses discovery and ordinary JavaScript promises inside a
|
|
731
877
|
* program, which `execute_code` builds over the same
|
|
732
878
|
* `CatalogService` and `InvocationService` these handlers use — one shared
|
|
@@ -742,44 +888,28 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
742
888
|
credentialHandoffUrl: ctx.credentialHandoffUrl,
|
|
743
889
|
requestSignal: ctx.requestSignal,
|
|
744
890
|
defer: ctx.defer,
|
|
891
|
+
approval: ctx.approval,
|
|
745
892
|
});
|
|
746
893
|
server.registerTool("skills", {
|
|
747
894
|
description: describedFor(registry, SKILLS_DESC, "skills"),
|
|
748
|
-
inputSchema:
|
|
895
|
+
inputSchema: SKILLS_INPUT,
|
|
749
896
|
annotations: READ_ONLY_LOCAL,
|
|
750
897
|
}, async (args) => mt.skills(args));
|
|
751
898
|
server.registerTool("search_tools", {
|
|
752
899
|
description: describedFor(registry, SEARCH_DESC, "search"),
|
|
753
|
-
inputSchema:
|
|
754
|
-
query: z.string().optional(),
|
|
755
|
-
connector: z.string().optional(),
|
|
756
|
-
safety: z
|
|
757
|
-
.enum(["readOnly", "approvalRequired", "all"])
|
|
758
|
-
.optional(),
|
|
759
|
-
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
|
|
760
|
-
offset: z.number().int().nonnegative().optional(),
|
|
761
|
-
fullDescriptions: z.boolean().optional(),
|
|
762
|
-
includeSchemas: z.enum(["compact", "json"]).optional(),
|
|
763
|
-
}),
|
|
900
|
+
inputSchema: SEARCH_INPUT,
|
|
764
901
|
annotations: READ_ONLY_REMOTE,
|
|
765
902
|
}, async (args) => mt.searchTools(args));
|
|
766
903
|
server.registerTool("call_tool", {
|
|
767
904
|
description: CALL_DESC,
|
|
768
|
-
inputSchema:
|
|
905
|
+
inputSchema: CALL_INPUT,
|
|
769
906
|
// call_tool admits only tools that are themselves explicitly read-only;
|
|
770
907
|
// anything else is refused and routed to call_destructive_tool.
|
|
771
908
|
annotations: READ_ONLY_REMOTE,
|
|
772
909
|
}, async (args) => mt.callTool(args));
|
|
773
910
|
server.registerTool("call_destructive_tool", {
|
|
774
911
|
description: describedFor(registry, CALL_DESTRUCTIVE_DESC, "destructive"),
|
|
775
|
-
inputSchema:
|
|
776
|
-
...CALL_INPUT_SCHEMA,
|
|
777
|
-
// Bounded above, but with no lower bound: a model that sends `""` or
|
|
778
|
-
// whitespace has written no reason, and failing an entire consequential
|
|
779
|
-
// call over a cosmetic field the host merely displays is the wrong
|
|
780
|
-
// trade. It is normalized to absent below instead.
|
|
781
|
-
reason: z.string().max(500).optional(),
|
|
782
|
-
}),
|
|
912
|
+
inputSchema: CALL_DESTRUCTIVE_INPUT,
|
|
783
913
|
annotations: {
|
|
784
914
|
destructiveHint: true,
|
|
785
915
|
readOnlyHint: false,
|
|
@@ -796,10 +926,7 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
796
926
|
});
|
|
797
927
|
server.registerTool("authorize_connector", {
|
|
798
928
|
description: AUTHORIZE_DESC,
|
|
799
|
-
inputSchema:
|
|
800
|
-
connector: z.string(),
|
|
801
|
-
force: z.boolean().optional(),
|
|
802
|
-
}),
|
|
929
|
+
inputSchema: AUTHORIZE_INPUT,
|
|
803
930
|
// Starts (or with force, resets) a downstream OAuth flow — it changes
|
|
804
931
|
// stored connector auth state, so it is deliberately not read-only.
|
|
805
932
|
annotations: {
|
|
@@ -810,15 +937,7 @@ export function registerMetaTools(server, registry, ctx) {
|
|
|
810
937
|
}, async (args) => mt.authorizeConnector(args));
|
|
811
938
|
server.registerTool("get_result", {
|
|
812
939
|
description: GET_RESULT_DESC,
|
|
813
|
-
inputSchema:
|
|
814
|
-
id: z.string(),
|
|
815
|
-
// Both bounds are the shared rules (isValidResultOffset,
|
|
816
|
-
// isValidMaxResultBytes) expressed for the wire: spelling them against
|
|
817
|
-
// the same constants keeps the schema from drifting away from the
|
|
818
|
-
// in-handler checks if either floor ever moves.
|
|
819
|
-
offset: z.number().int().min(MIN_RESULT_OFFSET).optional(),
|
|
820
|
-
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
821
|
-
}),
|
|
940
|
+
inputSchema: GET_RESULT_INPUT,
|
|
822
941
|
annotations: READ_ONLY_LOCAL,
|
|
823
942
|
}, async (args) => mt.getResult(args));
|
|
824
943
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { ConnectaBranding } from "./types.js";
|
|
1
|
+
import type { ConnectaBranding, Connector } from "./types.js";
|
|
2
2
|
import type { ActivityStore, ActivityReadGate, recordToolActivity, recordCatalogDriftActivity } from "./activity.js";
|
|
3
3
|
import type { RouteContext } from "./routes/shared.js";
|
|
4
|
+
import type { ArtifactRefreshRuntime } from "./artifacts/refresh.js";
|
|
4
5
|
/** Construction-time UI contract. Core never imports the implementation. */
|
|
5
6
|
export interface OperatorSurface {
|
|
6
7
|
readonly branding?: ConnectaBranding;
|
|
@@ -17,3 +18,22 @@ export interface ActivityModule {
|
|
|
17
18
|
readonly recordTool: typeof recordToolActivity;
|
|
18
19
|
readonly recordDrift: typeof recordCatalogDriftActivity;
|
|
19
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The artifacts module, created by `artifacts()` from /artifacts. Core appends
|
|
23
|
+
* its one prebuilt connector to the configured set — the same catalog,
|
|
24
|
+
* invocation, admission, and activity paths as any other — and imports none
|
|
25
|
+
* of its implementation.
|
|
26
|
+
*/
|
|
27
|
+
export interface ArtifactsModule {
|
|
28
|
+
/** The built-in `artifacts` connector. */
|
|
29
|
+
readonly connector: Connector;
|
|
30
|
+
/**
|
|
31
|
+
* Serve the pages' JSON API (`/artifacts/_api/*`) and the sandboxed frame
|
|
32
|
+
* (`/artifacts/_frame`); null for any other path. The operator UI delegates
|
|
33
|
+
* here and serves the page shells itself, so without `ui` there are no
|
|
34
|
+
* artifact routes at all.
|
|
35
|
+
*/
|
|
36
|
+
handle(context: RouteContext): Promise<Response | null>;
|
|
37
|
+
/** Bind the optional refresh runner after core has built its registry and executor. */
|
|
38
|
+
bindRefresh?(runtime: ArtifactRefreshRuntime): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { renderFixPrompt } from "./fix-prompt.js";
|
|
2
|
+
const OUTCOMES = {
|
|
3
|
+
connected: { status: 200, message: "Connected. You can close this window." },
|
|
4
|
+
denied: {
|
|
5
|
+
status: 400,
|
|
6
|
+
message: "Authorization was declined at the provider. Nothing was stored; start authorization again when you are ready.",
|
|
7
|
+
fix: {
|
|
8
|
+
problem: "Consent for a downstream OAuth connector was declined at the provider, so no grant was stored.",
|
|
9
|
+
steps: [
|
|
10
|
+
"If consent was declined on purpose, nothing in code needs to change: restart authorization from the operator page or authorize_connector when ready.",
|
|
11
|
+
"If the consent screen asked for more than the connector needs, narrow the scopes the connector's OAuth configuration requests.",
|
|
12
|
+
"If the provider refused the account or organization, check the OAuth app's allowed users or install policy in the provider's console.",
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
provider_error: {
|
|
17
|
+
status: 400,
|
|
18
|
+
message: "The provider returned an error instead of an authorization code. Nothing was stored.",
|
|
19
|
+
fix: {
|
|
20
|
+
problem: "A provider redirected back to the OAuth callback with an OAuth error instead of an authorization code.",
|
|
21
|
+
steps: [
|
|
22
|
+
"Compare the provider's OAuth client registration with the connector's configuration: client id, requested scopes, and the redirect URI, which is the deployment's public URL followed by /oauth/callback/<connector id>.",
|
|
23
|
+
"Check that the deployment's publicUrl is the origin the provider redirects to; a different origin yields a redirect URI the provider does not recognize.",
|
|
24
|
+
"Retry once before changing code: a provider outage answers with the same kind of error.",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
invalid_callback: {
|
|
29
|
+
status: 400,
|
|
30
|
+
message: "Authorization could not be completed. The link may be expired, already used, or replaced by a newer attempt. Re-run authorization from connecta and try again.",
|
|
31
|
+
fix: {
|
|
32
|
+
problem: "An OAuth callback arrived that the deployment could not match to an authorization flow it started: stale, already used, replaced by a newer attempt, or finished in a different session.",
|
|
33
|
+
steps: [
|
|
34
|
+
"Restart authorization from the operator page or authorize_connector and finish consent in one pass; restarting invalidates the previous link.",
|
|
35
|
+
"If it fails every time, confirm the connector implements both finishAuth and verifyState, and that the storage holding OAuth state is shared by every instance serving the deployment (memoryStorage is per-process).",
|
|
36
|
+
"For personal-auth connectors, finish consent signed in as the same operator who started it.",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
handoff_failed: {
|
|
41
|
+
status: 500,
|
|
42
|
+
message: "Authorization could not be completed. Nothing was exchanged; try again shortly.",
|
|
43
|
+
namesConnector: true,
|
|
44
|
+
fix: {
|
|
45
|
+
problem: "The deployment could not consume the stored principal handoff for a personal-auth OAuth flow, so no authorization code was exchanged.",
|
|
46
|
+
steps: [
|
|
47
|
+
"Check the deployment's storage adapter: that it is reachable, writable, and consistent across every instance serving the deployment.",
|
|
48
|
+
"Find the [connecta] warning for this callback in the deployment logs; it records the storage error the page withholds.",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
exchange_failed: {
|
|
53
|
+
status: 500,
|
|
54
|
+
message: "Authorization failed: the provider did not accept the authorization code exchange. Nothing was stored.",
|
|
55
|
+
namesConnector: true,
|
|
56
|
+
fix: {
|
|
57
|
+
problem: "A provider returned an authorization code, but exchanging it for tokens failed.",
|
|
58
|
+
steps: [
|
|
59
|
+
"Check the OAuth client credentials the connector uses — client id and secret in the deployment's environment — and that they belong to the same OAuth app that showed the consent screen.",
|
|
60
|
+
"Check that the token endpoint and redirect URI the connector uses match the provider's registration exactly.",
|
|
61
|
+
"Find the [connecta] warning for this callback in the deployment logs; it records the exchange error the page withholds.",
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const CONNECTOR_ID_RE = /^[a-z0-9_-]+$/;
|
|
67
|
+
/**
|
|
68
|
+
* RFC 6749 names `access_denied` for a declined consent (section 4.1.2.1). Every other
|
|
69
|
+
* value, standard or invented, is the provider refusing the request itself —
|
|
70
|
+
* and is never echoed, because the callback cannot tell a provider's code from
|
|
71
|
+
* one an attacker typed into the URL.
|
|
72
|
+
*/
|
|
73
|
+
export function providerErrorReason(error) {
|
|
74
|
+
return error === "access_denied" ? "denied" : "provider_error";
|
|
75
|
+
}
|
|
76
|
+
/** Page copy and fix prompt for one reason. Nothing here reads the request. */
|
|
77
|
+
export function oauthCallbackOutcome(reason, connectorId) {
|
|
78
|
+
const outcome = OUTCOMES[reason];
|
|
79
|
+
// Success is reached only after the state check proved the id configured,
|
|
80
|
+
// so it is the one page that may name the connector in its copy.
|
|
81
|
+
const message = reason === "connected" && connectorId && CONNECTOR_ID_RE.test(connectorId)
|
|
82
|
+
? `Connected "${connectorId}". You can close this window.`
|
|
83
|
+
: outcome.message;
|
|
84
|
+
return {
|
|
85
|
+
reason,
|
|
86
|
+
status: outcome.status,
|
|
87
|
+
message,
|
|
88
|
+
...(outcome.fix
|
|
89
|
+
? {
|
|
90
|
+
fixPrompt: renderFixPrompt(outcome.fix, outcome.namesConnector ? connectorId : undefined),
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
}
|