@zackbart/connecta 0.26.3 → 0.27.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 +5 -5
- package/CHANGELOG.md +72 -1
- package/bin/connecta.mjs +9 -1
- package/dist/access-tokens.js +13 -2
- package/dist/artifacts/connector.d.ts +3 -0
- package/dist/artifacts/connector.js +6 -0
- package/dist/artifacts/document.js +6 -0
- package/dist/artifacts/module.js +7 -1
- package/dist/artifacts/operations.d.ts +3 -1
- package/dist/artifacts/operations.js +68 -10
- package/dist/artifacts/refresh.d.ts +2 -0
- package/dist/artifacts/refresh.js +3 -2
- package/dist/artifacts/routes.js +4 -15
- package/dist/artifacts/validate.js +1 -2
- package/dist/auth/downstream-oauth.js +95 -36
- package/dist/connector-scope.d.ts +2 -0
- package/dist/connector-scope.js +27 -0
- package/dist/connectors/api.js +8 -2
- package/dist/connectors/remote-mcp.js +57 -17
- package/dist/credentials.js +2 -1
- package/dist/execute.js +15 -0
- package/dist/index.js +1 -0
- package/dist/oauth-partition.js +32 -0
- package/dist/operator-ui/generated.js +1 -1
- package/dist/providers/notion.js +4 -1
- package/dist/providers/vercel.js +13 -9
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +58 -25
- package/dist/resumable.js +42 -10
- package/dist/routes/credentials.js +16 -8
- package/dist/routes/oauth.js +70 -64
- package/dist/runtime/run.js +7 -1
- package/dist/storage/file.js +57 -20
- package/dist/storage/memory.js +8 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +44 -3
- package/documentation/auth.md +7 -4
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/d1-storage.ts +27 -5
- package/package.json +3 -3
- package/templates/node/package.json +2 -2
package/dist/providers/notion.js
CHANGED
|
@@ -147,7 +147,10 @@ async function notionRequest(ctx, request) {
|
|
|
147
147
|
if (!response.ok) {
|
|
148
148
|
throw notionFailure(response.status, payload, response.headers);
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
151
|
+
throw new ConnectorCallError("connector_call_failed", "Notion returned an unreadable or non-object JSON response for a successful status. The request was sent, but its result could not be read; do not repeat a write to recover its result.", { retryable: false });
|
|
152
|
+
}
|
|
153
|
+
return payload;
|
|
151
154
|
});
|
|
152
155
|
}
|
|
153
156
|
// Projections. Notion wraps every property in a discriminated object, every
|
package/dist/providers/vercel.js
CHANGED
|
@@ -83,7 +83,7 @@ function vercelFailure(status, headers, payload) {
|
|
|
83
83
|
retryable: false,
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
|
-
function parseBody(text, contentType) {
|
|
86
|
+
function parseBody(text, contentType, strict = false) {
|
|
87
87
|
if (!text)
|
|
88
88
|
return undefined;
|
|
89
89
|
try {
|
|
@@ -91,6 +91,8 @@ function parseBody(text, contentType) {
|
|
|
91
91
|
}
|
|
92
92
|
catch {
|
|
93
93
|
if (contentType?.includes("stream+json") || contentType?.includes("ndjson")) {
|
|
94
|
+
if (strict)
|
|
95
|
+
return parseStreamRows(text);
|
|
94
96
|
const rows = [];
|
|
95
97
|
for (const line of text.split("\n")) {
|
|
96
98
|
if (!line.trim())
|
|
@@ -104,6 +106,9 @@ function parseBody(text, contentType) {
|
|
|
104
106
|
}
|
|
105
107
|
return rows;
|
|
106
108
|
}
|
|
109
|
+
if (strict) {
|
|
110
|
+
throw new ConnectorCallError("connector_call_failed", "Vercel returned a malformed successful response.", { retryable: false });
|
|
111
|
+
}
|
|
107
112
|
return text;
|
|
108
113
|
}
|
|
109
114
|
}
|
|
@@ -144,14 +149,13 @@ function vercelTransport(baseUrl) {
|
|
|
144
149
|
},
|
|
145
150
|
});
|
|
146
151
|
}
|
|
147
|
-
async function callVercel(send, request, ctx,
|
|
152
|
+
async function callVercel(send, request, ctx, success = {}) {
|
|
148
153
|
return await send(request, ctx, async (response) => {
|
|
149
154
|
const text = await response.text();
|
|
150
|
-
const payload = parseBody(text, response.headers.get("content-type"));
|
|
151
155
|
if (!response.ok) {
|
|
152
|
-
throw vercelFailure(response.status, response.headers,
|
|
156
|
+
throw vercelFailure(response.status, response.headers, parseBody(text, response.headers.get("content-type")));
|
|
153
157
|
}
|
|
154
|
-
return
|
|
158
|
+
return success.parse ? success.parse(text) : parseBody(text, response.headers.get("content-type"), !success.raw);
|
|
155
159
|
});
|
|
156
160
|
}
|
|
157
161
|
function teamQuery(args, defaultTeamId) {
|
|
@@ -553,7 +557,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
553
557
|
required: ["result"],
|
|
554
558
|
},
|
|
555
559
|
handler: async (args, ctx) => ({
|
|
556
|
-
result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx)) ?? null,
|
|
560
|
+
result: (await callVercel(send, { method: "GET", ...rawRequest(args, defaultTeamId) }, ctx, { raw: true })) ?? null,
|
|
557
561
|
}),
|
|
558
562
|
},
|
|
559
563
|
{
|
|
@@ -588,7 +592,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
588
592
|
method: args["method"],
|
|
589
593
|
...rawRequest(args, defaultTeamId),
|
|
590
594
|
...(args["body"] !== undefined ? { body: args["body"] } : {}),
|
|
591
|
-
}, ctx)) ?? null,
|
|
595
|
+
}, ctx, { raw: true })) ?? null,
|
|
592
596
|
}),
|
|
593
597
|
},
|
|
594
598
|
{
|
|
@@ -641,7 +645,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
641
645
|
"Content-Type": args["contentType"],
|
|
642
646
|
},
|
|
643
647
|
rawBody: uploadBody(args),
|
|
644
|
-
}, ctx)) ?? null,
|
|
648
|
+
}, ctx, { raw: true })) ?? null,
|
|
645
649
|
}),
|
|
646
650
|
},
|
|
647
651
|
{
|
|
@@ -839,7 +843,7 @@ function tools(send, defaultPageSize, defaultTeamId) {
|
|
|
839
843
|
method: "GET",
|
|
840
844
|
path: `/v1/projects/${encodeURIComponent(args["projectId"])}/deployments/${encodeURIComponent(args["deploymentId"])}/runtime-logs`,
|
|
841
845
|
query: team(args), headers: { Accept: "application/stream+json" },
|
|
842
|
-
}, { ...ctx, signal }, parseStreamRows), {
|
|
846
|
+
}, { ...ctx, signal }, { parse: parseStreamRows }), {
|
|
843
847
|
timeoutMs: RUNTIME_LOG_TIMEOUT_MS,
|
|
844
848
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
845
849
|
timeoutError: new ConnectorCallError("unavailable", `Vercel's runtime-log stream stayed open past this connector's ${RUNTIME_LOG_TIMEOUT_MS / 1_000}-second bound. Retry for a fresh snapshot.`),
|
package/dist/registry.d.ts
CHANGED
|
@@ -171,6 +171,7 @@ export declare class Registry implements RegistryView {
|
|
|
171
171
|
private resultStashBytes;
|
|
172
172
|
private readonly configuredConnectors;
|
|
173
173
|
private readonly personalRegistries;
|
|
174
|
+
private readonly oauthPartition;
|
|
174
175
|
private callAdmissionClosed;
|
|
175
176
|
/** Bounded FIFO of absent grants already warned about. */
|
|
176
177
|
private readonly warnedAbsentGrants;
|
package/dist/registry.js
CHANGED
|
@@ -8,6 +8,7 @@ import { MAX_CATALOG_CHUNK_BYTES, MAX_CATALOG_TOOLS, MAX_SERIALIZED_CATALOG_BYTE
|
|
|
8
8
|
import { ObservedOutputSchemas } from "./result-shapes.js";
|
|
9
9
|
import { GUIDE_SUMMARY_LENGTH, normalizeGuideSummary, } from "./skills.js";
|
|
10
10
|
import { attachOAuthSealer, vaultOAuthSealer } from "./oauth-sealing.js";
|
|
11
|
+
import { attachOAuthPartition, oauthPartitionIdle } from "./oauth-partition.js";
|
|
11
12
|
import { attachCaller } from "./connector-caller.js";
|
|
12
13
|
import { closeScopeOnExit } from "./runtime/connector-scope.js";
|
|
13
14
|
import { detach, runEdge, withDeadlineEffect } from "./runtime/run.js";
|
|
@@ -164,6 +165,7 @@ export class Registry {
|
|
|
164
165
|
resultStashBytes = 0;
|
|
165
166
|
configuredConnectors;
|
|
166
167
|
personalRegistries = new Map();
|
|
168
|
+
oauthPartition = {};
|
|
167
169
|
callAdmissionClosed = false;
|
|
168
170
|
/** Bounded FIFO of absent grants already warned about. */
|
|
169
171
|
warnedAbsentGrants = new Set();
|
|
@@ -226,9 +228,12 @@ export class Registry {
|
|
|
226
228
|
// principal starts with no generations and no mutation queue, so a
|
|
227
229
|
// refresh the evicted registry finishes later would persist a listing
|
|
228
230
|
// that a credential change on the replacement had just deleted.
|
|
231
|
+
// OAuth status/start work bypasses both gates, and accepted rotations
|
|
232
|
+
// may still be saving after their caller leaves. Preserve its partition.
|
|
229
233
|
const idle = [...this.personalRegistries].find(([, candidate]) => [...candidate.callAdmission.values()].every(admission => admission.isIdle()) &&
|
|
230
234
|
candidate.catalogRefreshes.size === 0 &&
|
|
231
|
-
candidate.catalogMutations.size === 0
|
|
235
|
+
candidate.catalogMutations.size === 0 &&
|
|
236
|
+
oauthPartitionIdle(candidate.oauthPartition));
|
|
232
237
|
if (!idle) {
|
|
233
238
|
throw new Error("Personal connector capacity is exhausted; retry after calls, rolling budgets, and catalog refreshes drain.");
|
|
234
239
|
}
|
|
@@ -287,11 +292,24 @@ export class Registry {
|
|
|
287
292
|
}
|
|
288
293
|
async storeOAuthHandoff(connectorId, state, principalKey) {
|
|
289
294
|
const key = this.oauthHandoffKey(connectorId, await sha256Hex(state));
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
295
|
+
for (let attempt = 0; attempt < 32; attempt++) {
|
|
296
|
+
const existing = await this.opts.storage.get(key);
|
|
297
|
+
if (existing && existing !== principalKey) {
|
|
298
|
+
throw new Error(`Connector "${connectorId}" reused one OAuth state across principals`);
|
|
299
|
+
}
|
|
300
|
+
const expiry = { ttlSeconds: OAUTH_HANDOFF_TTL_SECONDS };
|
|
301
|
+
if (this.opts.storage.compareAndSet) {
|
|
302
|
+
// Bind ownership atomically, including renewal of the same owner's
|
|
303
|
+
// handoff. Two owners reading a miss must not overwrite each other.
|
|
304
|
+
if (!await this.opts.storage.compareAndSet(key, existing, principalKey, expiry))
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
await this.opts.storage.set(key, principalKey, expiry);
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
293
311
|
}
|
|
294
|
-
|
|
312
|
+
throw new Error(`OAuth handoff for "${connectorId}" is busy; retry authorization`);
|
|
295
313
|
}
|
|
296
314
|
async oauthCallbackView(connectorId, state) {
|
|
297
315
|
const connector = this.connectors.get(connectorId);
|
|
@@ -389,7 +407,10 @@ export class Registry {
|
|
|
389
407
|
return values;
|
|
390
408
|
};
|
|
391
409
|
credentialAccess = {
|
|
392
|
-
get: async (field = "value") =>
|
|
410
|
+
get: async (field = "value") => {
|
|
411
|
+
const values = await readValues();
|
|
412
|
+
return values && Object.hasOwn(values, field) ? values[field] : null;
|
|
413
|
+
},
|
|
393
414
|
getAll: readValues,
|
|
394
415
|
};
|
|
395
416
|
}
|
|
@@ -401,6 +422,7 @@ export class Registry {
|
|
|
401
422
|
requestScope,
|
|
402
423
|
...callOptions,
|
|
403
424
|
};
|
|
425
|
+
attachOAuthPartition(context, this.oauthPartition);
|
|
404
426
|
// Downstream OAuth state is sealed under the vault key, bound to this
|
|
405
427
|
// connector and owner. The sealer rides beside the context, not on it.
|
|
406
428
|
return this.opts.credentialVault
|
|
@@ -818,8 +840,12 @@ export class Registry {
|
|
|
818
840
|
const turn = Deferred.makeUnsafe();
|
|
819
841
|
this.catalogMutations.set(id, turn);
|
|
820
842
|
try {
|
|
821
|
-
if (previous)
|
|
822
|
-
|
|
843
|
+
if (previous) {
|
|
844
|
+
// A cross-request Deferred alone looks hung to workerd. Keep a timer
|
|
845
|
+
// owned by this request until the preceding mutation hands over its
|
|
846
|
+
// turn; the race interrupts the timer as soon as the wait settles.
|
|
847
|
+
await runEdge(Effect.raceFirst(Deferred.await(previous), Effect.forever(Effect.sleep(Duration.seconds(1)))));
|
|
848
|
+
}
|
|
823
849
|
await runOnPartition(operation, this.opts);
|
|
824
850
|
}
|
|
825
851
|
finally {
|
|
@@ -841,14 +867,8 @@ export class Registry {
|
|
|
841
867
|
// observation, but must not overwrite a newer same-generation refresh.
|
|
842
868
|
// Blocking callers do not set this flag and retain their prior publication
|
|
843
869
|
// behavior when an inbound abort races a completed listing.
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
// The caller that began this refresh may still use its result, but a
|
|
847
|
-
// credential/OAuth change that landed while listTools was in flight, or a
|
|
848
|
-
// flight abandoned while it listed, means the listing must not enter
|
|
849
|
-
// either shared cache layer.
|
|
850
|
-
if (!this.mayPublish(id, generation, flight))
|
|
851
|
-
return tools;
|
|
870
|
+
// Every returned listing must pass the ceilings, even when invalidation
|
|
871
|
+
// or abandonment means it can no longer enter either shared cache layer.
|
|
852
872
|
if (tools.length > MAX_CATALOG_TOOLS) {
|
|
853
873
|
const message = `Connector "${id}" returned ${tools.length} tools, over the ` +
|
|
854
874
|
`${MAX_CATALOG_TOOLS}-tool catalog ceiling; refusing the complete catalog.`;
|
|
@@ -857,10 +877,6 @@ export class Registry {
|
|
|
857
877
|
}
|
|
858
878
|
const previous = this.cache.get(id);
|
|
859
879
|
const snapshot = await snapshotCatalog(tools);
|
|
860
|
-
if (!this.mayPublish(id, generation, flight) ||
|
|
861
|
-
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
862
|
-
return tools;
|
|
863
|
-
}
|
|
864
880
|
if (snapshot.serializedBytes.byteLength > MAX_SERIALIZED_CATALOG_BYTES) {
|
|
865
881
|
const message = `Connector "${id}" returned a ${snapshot.serializedBytes.byteLength}-byte ` +
|
|
866
882
|
`serialized catalog, over the ${MAX_SERIALIZED_CATALOG_BYTES}-byte ceiling; ` +
|
|
@@ -868,6 +884,13 @@ export class Registry {
|
|
|
868
884
|
this.opts.logger.warn(`[connecta] ${message}`);
|
|
869
885
|
throw new Error(message);
|
|
870
886
|
}
|
|
887
|
+
// The caller that began this refresh may still use its bounded result,
|
|
888
|
+
// but credential changes, abandoned flights, and deferred cancellation
|
|
889
|
+
// prevent publication. Recheck after snapshotting, which is asynchronous.
|
|
890
|
+
if (!this.mayPublish(id, generation, flight) ||
|
|
891
|
+
(skipPublicationWhenAborted && ctx.signal?.aborted)) {
|
|
892
|
+
return tools;
|
|
893
|
+
}
|
|
871
894
|
const now = Date.now();
|
|
872
895
|
const catalogChanged = !previous || previous.fingerprint !== snapshot.fingerprint;
|
|
873
896
|
const shouldPersist = catalogChanged ||
|
|
@@ -1296,23 +1319,33 @@ class ScopedRegistryView {
|
|
|
1296
1319
|
allowed;
|
|
1297
1320
|
scope;
|
|
1298
1321
|
maxResultBytes;
|
|
1299
|
-
|
|
1322
|
+
/** An evicted view's admission controller must remain closed. */
|
|
1323
|
+
admissionPersonal;
|
|
1324
|
+
get personal() {
|
|
1325
|
+
return this.scope.principalKey
|
|
1326
|
+
? this.root.personalRegistry(this.scope.principalKey)
|
|
1327
|
+
: undefined;
|
|
1328
|
+
}
|
|
1300
1329
|
constructor(root, allowed, scope) {
|
|
1301
1330
|
this.root = root;
|
|
1302
1331
|
this.allowed = allowed;
|
|
1303
1332
|
this.scope = scope;
|
|
1304
1333
|
this.maxResultBytes = root.maxResultBytes;
|
|
1305
|
-
|
|
1334
|
+
// Check capacity at construction, then resolve the current registry on
|
|
1335
|
+
// use. A retained view must not revive one evicted while it was idle.
|
|
1336
|
+
this.admissionPersonal = scope.principalKey
|
|
1306
1337
|
? root.personalRegistry(scope.principalKey)
|
|
1307
1338
|
: undefined;
|
|
1308
1339
|
}
|
|
1309
|
-
registryFor(id) {
|
|
1340
|
+
registryFor(id, admission = false) {
|
|
1310
1341
|
if (!this.allowed.has(id))
|
|
1311
1342
|
return undefined;
|
|
1312
1343
|
const connector = this.root.getConnector(id);
|
|
1313
1344
|
if (!connector)
|
|
1314
1345
|
return undefined;
|
|
1315
|
-
|
|
1346
|
+
if (connector.authScope !== "personal")
|
|
1347
|
+
return this.root;
|
|
1348
|
+
return admission ? this.admissionPersonal : this.personal;
|
|
1316
1349
|
}
|
|
1317
1350
|
listConnectors() {
|
|
1318
1351
|
return this.root.listConnectors().filter((connector) => this.registryFor(connector.id) !== undefined);
|
|
@@ -1358,7 +1391,7 @@ class ScopedRegistryView {
|
|
|
1358
1391
|
return attachCaller(registry.contextFor(...args), this.scope.caller);
|
|
1359
1392
|
}
|
|
1360
1393
|
admitCall(...args) {
|
|
1361
|
-
const registry = this.registryFor(args[0]);
|
|
1394
|
+
const registry = this.registryFor(args[0], true);
|
|
1362
1395
|
if (!registry) {
|
|
1363
1396
|
return Promise.reject(new Error(`Unknown connector "${args[0]}"`));
|
|
1364
1397
|
}
|
package/dist/resumable.js
CHANGED
|
@@ -226,7 +226,9 @@ export class RunState {
|
|
|
226
226
|
/** A claimed replay of a paused run. */
|
|
227
227
|
static replaying(options) {
|
|
228
228
|
const { header } = options.claim;
|
|
229
|
-
|
|
229
|
+
const run = new RunState(options.journal, options.program, { clockMs: header.clock, seed: header.seed }, "resume_execution", options.settings, options.claim, new ReplayState(options.entries, header.pending?.key));
|
|
230
|
+
run.writesSpent = header.writes.length;
|
|
231
|
+
return run;
|
|
230
232
|
}
|
|
231
233
|
// --- numbering and the gate sequencer ---------------------------------
|
|
232
234
|
/**
|
|
@@ -325,12 +327,9 @@ export class RunState {
|
|
|
325
327
|
lookup(key) {
|
|
326
328
|
if (!this.replay)
|
|
327
329
|
return { kind: "live" };
|
|
328
|
-
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
if (found.kind === "hit" && found.entry.write)
|
|
332
|
-
this.writesSpent++;
|
|
333
|
-
return found;
|
|
330
|
+
// Already-sent writes spend the run budget whether this play repeats them
|
|
331
|
+
// or diverges before doing so. Only new dispatches spend it again.
|
|
332
|
+
return this.replay.lookup(key);
|
|
334
333
|
}
|
|
335
334
|
/** Journal a completed live call that was not a write. */
|
|
336
335
|
record(seq, op, key, outcome) {
|
|
@@ -546,11 +545,41 @@ export class RunState {
|
|
|
546
545
|
: undefined;
|
|
547
546
|
if (this.claim && slot !== undefined) {
|
|
548
547
|
const claim = this.claim;
|
|
548
|
+
const entryBytes = utf8Bytes(RunJournal.entryText(entry));
|
|
549
|
+
let tooLarge = false;
|
|
550
|
+
// Reserve capacity through the same serialized CAS as the write-ahead
|
|
551
|
+
// marks. Concurrent answers cannot both spend the last free bytes.
|
|
552
|
+
const capacity = yield* this.mutateHeader((header) => {
|
|
553
|
+
const pendingBytes = this.unpersisted.reduce((sum, pending) => sum + utf8Bytes(RunJournal.entryText(pending)), 0);
|
|
554
|
+
tooLarge = header.bytes + pendingBytes + entryBytes > MAX_JOURNAL_BYTES;
|
|
555
|
+
if (tooLarge) {
|
|
556
|
+
this.stopWith({
|
|
557
|
+
kind: "failed",
|
|
558
|
+
failure: unknownFailure ?? failure("journal_too_large", `This run recorded more than ${MAX_JOURNAL_BYTES} bytes of source and host calls, too much to keep in its journal. Writes it already sent are counted in writes and are not undone; check them before running the task again.`),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
...header,
|
|
563
|
+
bytes: tooLarge ? header.bytes : header.bytes + entryBytes,
|
|
564
|
+
writes: tooLarge
|
|
565
|
+
? header.writes.map((write) => write.entry === slot ? { ...write, state } : write)
|
|
566
|
+
: header.writes,
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
if (capacity !== "ok") {
|
|
570
|
+
this.noteLocally(slot, state);
|
|
571
|
+
const lost = capacity === "lost"
|
|
572
|
+
? failure("execution_claim_lost", `The write to ${target.address} was sent (${describeState(state)}), but another resume_execution took the run over before its result could be journaled.`)
|
|
573
|
+
: interrupted(`The write to ${target.address} was sent (${describeState(state)}), but paused-run storage failed before its result could be journaled.`);
|
|
574
|
+
this.stopWith({ kind: "failed", failure: lost });
|
|
575
|
+
return guestFailure(lost.code, lost.message);
|
|
576
|
+
}
|
|
577
|
+
if (tooLarge)
|
|
578
|
+
return this.halted();
|
|
549
579
|
const written = yield* Effect.tryPromise(() => this.journal.writeEntry(slot, entry, claim.header.expiresAt)).pipe(Effect.as(true), Effect.catch(() => Effect.succeed(false)));
|
|
550
580
|
const recorded = written ? state : "unknown";
|
|
551
581
|
const settled = yield* this.mutateHeader((header) => ({
|
|
552
582
|
...header,
|
|
553
|
-
bytes: header.bytes + utf8Bytes(RunJournal.entryText(entry)),
|
|
554
583
|
writes: header.writes.map((write) => write.entry === slot ? { ...write, state: recorded } : write),
|
|
555
584
|
}));
|
|
556
585
|
if (settled !== "ok") {
|
|
@@ -670,8 +699,11 @@ export class RunState {
|
|
|
670
699
|
const stop = this.stop;
|
|
671
700
|
if (!stop)
|
|
672
701
|
return Effect.die(new Error("finishStopped without a stop"));
|
|
673
|
-
if (stop.kind === "failed")
|
|
674
|
-
return this.finishFailed(stop.failure)
|
|
702
|
+
if (stop.kind === "failed") {
|
|
703
|
+
return this.finishFailed(stop.failure.code === "journal_too_large" && this.writeCounts().unknown > 0
|
|
704
|
+
? unknownOutcome()
|
|
705
|
+
: stop.failure);
|
|
706
|
+
}
|
|
675
707
|
if (this.writeCounts().unknown > 0)
|
|
676
708
|
return this.finishFailed(unknownOutcome());
|
|
677
709
|
return this.persistPause(stop.pending);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { claimConnectorScopeCleanup, closeConnectorScope } from "../connector-scope.js";
|
|
2
3
|
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
|
|
3
4
|
import { authorizedPerson, refuse, serveOperator, visibleRegistry, } from "./operator.js";
|
|
4
5
|
import { mayManageConnector, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
@@ -164,14 +165,21 @@ function credentialRequest(context, connectorId, action) {
|
|
|
164
165
|
}
|
|
165
166
|
const storedValues = values;
|
|
166
167
|
const ctx = registry.contextFor(connectorId, baseUrl);
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
const releaseCleanup = claimConnectorScopeCleanup(ctx, connectorId);
|
|
169
|
+
try {
|
|
170
|
+
const result = mode === "multiple"
|
|
171
|
+
? await connector.testCredentials(storedValues, ctx)
|
|
172
|
+
: await connector.testCredential(
|
|
173
|
+
// The single-value shape check above guarantees this key.
|
|
174
|
+
storedValues.value, ctx);
|
|
175
|
+
const ok = result?.ok === true;
|
|
176
|
+
logged(ok, result?.message);
|
|
177
|
+
return privateJson({ ok });
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
releaseCleanup();
|
|
181
|
+
await closeConnectorScope(connector, ctx, context.defer);
|
|
182
|
+
}
|
|
175
183
|
},
|
|
176
184
|
catch: (error) => error,
|
|
177
185
|
}).pipe(Effect.catch((error) => {
|
package/dist/routes/oauth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect";
|
|
2
|
+
import { closeConnectorScope } from "../connector-scope.js";
|
|
2
3
|
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
3
4
|
import { escapeHtml, renderPage, resolveBranding, STATUS_ICONS } from "../branding.js";
|
|
4
5
|
import { oauthCallbackOutcome, providerErrorReason, } from "../oauth-callback-outcome.js";
|
|
@@ -127,79 +128,84 @@ async function finishOAuthCallback(context) {
|
|
|
127
128
|
await equalizeRefusalCost(connectorContext);
|
|
128
129
|
return refused();
|
|
129
130
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
131
|
+
try {
|
|
132
|
+
const expectedPrincipalKey = callbackTarget?.principalKey;
|
|
133
|
+
// A browser returning from consent normally has no MCP Authorization
|
|
134
|
+
// header. An interactive bearer provider therefore answers 401 here; state
|
|
135
|
+
// and the saved state-to-principal handoff still prove ownership below.
|
|
136
|
+
// Rejecting 401 would break that callback. A 403 is an explicit denial.
|
|
137
|
+
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
|
|
138
|
+
if (browserIdentity.ok) {
|
|
139
|
+
try {
|
|
140
|
+
validateAuthPermissions(browserIdentity, opts.registry);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return refused();
|
|
144
|
+
}
|
|
145
|
+
if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
|
|
146
|
+
return refused();
|
|
139
147
|
}
|
|
140
|
-
|
|
148
|
+
else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
|
|
141
149
|
return refused();
|
|
142
150
|
}
|
|
143
|
-
|
|
151
|
+
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
152
|
+
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
153
|
+
if (!connector.verifyState) {
|
|
154
|
+
await equalizeRefusalCost(connectorContext);
|
|
155
|
+
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
156
|
+
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
157
|
+
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
158
|
+
"No authorization code was exchanged. Implement verifyState before " +
|
|
159
|
+
"trying again.");
|
|
144
160
|
return refused();
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return refused();
|
|
148
|
-
}
|
|
149
|
-
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
150
|
-
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
151
|
-
if (!connector.verifyState) {
|
|
152
|
-
await equalizeRefusalCost(connectorContext);
|
|
153
|
-
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
154
|
-
`${loggableValue(id)} with 400: it implements finishAuth but no ` +
|
|
155
|
-
"verifyState, so connecta cannot establish that it started this flow. " +
|
|
156
|
-
"No authorization code was exchanged. Implement verifyState before " +
|
|
157
|
-
"trying again.");
|
|
158
|
-
return refused();
|
|
159
|
-
}
|
|
160
|
-
let stateMatches;
|
|
161
|
-
try {
|
|
162
|
-
stateMatches = await connector.verifyState(state, connectorContext);
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
166
|
-
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
167
|
-
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
168
|
-
"Re-run authorization from connecta and check the verifier if it " +
|
|
169
|
-
"fails again.");
|
|
170
|
-
return refused();
|
|
171
|
-
}
|
|
172
|
-
if (!stateMatches) {
|
|
173
|
-
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
174
|
-
`${loggableValue(id)} with 400: ` +
|
|
175
|
-
(state === null
|
|
176
|
-
? "the state parameter was missing"
|
|
177
|
-
: "the state did not match the pending authorization flow") +
|
|
178
|
-
". No authorization code was exchanged. Re-run authorization from " +
|
|
179
|
-
"connecta and try again.");
|
|
180
|
-
return refused();
|
|
181
|
-
}
|
|
182
|
-
if (connector.authScope === "personal") {
|
|
161
|
+
}
|
|
162
|
+
let stateMatches;
|
|
183
163
|
try {
|
|
184
|
-
await
|
|
164
|
+
stateMatches = await connector.verifyState(state, connectorContext);
|
|
185
165
|
}
|
|
186
166
|
catch (err) {
|
|
187
167
|
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
188
|
-
`${loggableValue(id)} with
|
|
189
|
-
|
|
190
|
-
|
|
168
|
+
`${loggableValue(id)} with 400: verifyState threw ` +
|
|
169
|
+
`${loggableValue(msg(err))}. No authorization code was exchanged. ` +
|
|
170
|
+
"Re-run authorization from connecta and check the verifier if it " +
|
|
171
|
+
"fails again.");
|
|
172
|
+
return refused();
|
|
173
|
+
}
|
|
174
|
+
if (!stateMatches) {
|
|
175
|
+
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
176
|
+
`${loggableValue(id)} with 400: ` +
|
|
177
|
+
(state === null
|
|
178
|
+
? "the state parameter was missing"
|
|
179
|
+
: "the state did not match the pending authorization flow") +
|
|
180
|
+
". No authorization code was exchanged. Re-run authorization from " +
|
|
181
|
+
"connecta and try again.");
|
|
182
|
+
return refused();
|
|
183
|
+
}
|
|
184
|
+
if (connector.authScope === "personal") {
|
|
185
|
+
try {
|
|
186
|
+
await opts.registry.clearOAuthHandoff(id, state);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
190
|
+
`${loggableValue(id)} with 500: its principal handoff could not be ` +
|
|
191
|
+
`consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
|
|
192
|
+
return html("handoff_failed", opts, connector);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
197
|
+
await callbackRegistry.invalidateStored(id);
|
|
198
|
+
return html("connected", opts, connector);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
// The page names the reason and nothing else; what the exchange threw can
|
|
202
|
+
// quote a token endpoint's body, so it goes only to the operator log.
|
|
203
|
+
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
204
|
+
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
205
|
+
return html("exchange_failed", opts, connector);
|
|
191
206
|
}
|
|
192
207
|
}
|
|
193
|
-
|
|
194
|
-
await connector
|
|
195
|
-
await callbackRegistry.invalidateStored(id);
|
|
196
|
-
return html("connected", opts, connector);
|
|
197
|
-
}
|
|
198
|
-
catch (err) {
|
|
199
|
-
// The page names the reason and nothing else; what the exchange threw can
|
|
200
|
-
// quote a token endpoint's body, so it goes only to the operator log.
|
|
201
|
-
opts.logger.warn(`[connecta] OAuth callback for connector ${loggableValue(id)} failed ` +
|
|
202
|
-
`with 500: the authorization code exchange threw ${loggableValue(msg(err))}.`);
|
|
203
|
-
return html("exchange_failed", opts, connector);
|
|
208
|
+
finally {
|
|
209
|
+
await closeConnectorScope(connector, connectorContext, context.defer);
|
|
204
210
|
}
|
|
205
211
|
}
|
package/dist/runtime/run.js
CHANGED
|
@@ -34,7 +34,9 @@ function setMicrotask(task) {
|
|
|
34
34
|
export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
|
|
35
35
|
const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
|
|
36
36
|
function abortReason(signal) {
|
|
37
|
-
return signal
|
|
37
|
+
return signal && signal.reason !== undefined
|
|
38
|
+
? signal.reason
|
|
39
|
+
: new DOMException("aborted", "AbortError");
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
42
|
* Turn a failed Exit back into the value a Promise caller would have seen.
|
|
@@ -136,6 +138,10 @@ export function fromSignal(signal) {
|
|
|
136
138
|
*/
|
|
137
139
|
export function withDeadlineEffect(operation, options) {
|
|
138
140
|
return Effect.suspend(() => {
|
|
141
|
+
// Do not dispatch work that already lost its caller. The operation is the
|
|
142
|
+
// first race contender and can finish before the abort contender starts.
|
|
143
|
+
if (options.signal?.aborted)
|
|
144
|
+
return Effect.fail(abortReason(options.signal));
|
|
139
145
|
const controller = new AbortController();
|
|
140
146
|
const forwardAbort = () => controller.abort(options.signal?.reason);
|
|
141
147
|
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|