@zackbart/connecta 0.40.0 → 0.41.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/CHANGELOG.md +87 -0
- package/bin/version.mjs +1 -1
- package/dist/activity.d.ts +30 -4
- package/dist/activity.js +28 -2
- package/dist/agent-observation.js +24 -2
- package/dist/agent-routes.js +4 -41
- package/dist/auth/clerk.d.ts +6 -1
- package/dist/auth/clerk.js +16 -0
- package/dist/auth/cloudflare-access.js +49 -7
- package/dist/auth/downstream-oauth/consent.js +79 -29
- package/dist/auth/downstream-oauth/grant-store.js +58 -7
- package/dist/auth/downstream-oauth/refresh-records.js +6 -5
- package/dist/auth/downstream-oauth.js +12 -4
- package/dist/auth/oauth-request-not-sent.js +4 -0
- package/dist/auth/static-oauth.js +51 -21
- package/dist/catalog-cache.js +62 -21
- package/dist/catalog-freshness.js +13 -0
- package/dist/catalog-service.js +39 -12
- package/dist/catalog.js +103 -38
- package/dist/config.d.ts +10 -4
- package/dist/config.js +30 -7
- package/dist/connectors/remote-mcp/call.js +1 -1
- package/dist/connectors/remote-mcp/connection.js +52 -18
- package/dist/connectors/remote-mcp/oauth.js +2 -2
- package/dist/connectors/remote-mcp/resources.js +5 -2
- package/dist/connectors/remote-mcp/transport.js +24 -6
- package/dist/connectors/remote-mcp.js +4 -4
- package/dist/credential-contract.d.ts +3 -1
- package/dist/describe-config.d.ts +1 -0
- package/dist/describe-config.js +1 -0
- package/dist/downstream-input.js +11 -2
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +2 -0
- package/dist/execute.js +20 -7
- package/dist/executor-result.js +1 -1
- package/dist/executors/quickjs-child.js +1 -1
- package/dist/executors/quickjs-runtime.js +1 -1
- package/dist/executors/quickjs.js +1 -1
- package/dist/guarded-transport.js +2 -0
- package/dist/http/client.js +7 -0
- package/dist/http/hatch.js +2 -0
- package/dist/http/node-transport.js +110 -4
- package/dist/http/oauth.js +17 -6
- package/dist/index.js +10 -21
- package/dist/invocation-failure.js +28 -0
- package/dist/invocation.js +72 -33
- package/dist/membership.d.ts +1 -1
- package/dist/meta-tools.js +27 -13
- package/dist/module-contracts.d.ts +3 -1
- package/dist/native-tools.js +11 -4
- package/dist/node.d.ts +7 -0
- package/dist/node.js +82 -3
- package/dist/operation-activity.js +103 -0
- package/dist/operator-record.d.ts +8 -0
- package/dist/operator-record.js +15 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/provider.d.ts +1 -1
- package/dist/providers/_shared/google/workspace.d.ts +1 -1
- package/dist/providers/breeze/index.js +1 -1
- package/dist/providers/ccb/index.js +1 -1
- package/dist/providers/cloudflare/index.js +1 -1
- package/dist/providers/docs/index.js +1 -1
- package/dist/providers/drive/index.js +1 -1
- package/dist/providers/forms/index.js +1 -1
- package/dist/providers/github/index.js +1 -1
- package/dist/providers/gmail/index.js +1 -1
- package/dist/providers/granola/index.js +1 -1
- package/dist/providers/overflow/index.js +1 -1
- package/dist/providers/planning-center/index.js +1 -1
- package/dist/providers/planning-center/lib/constants.d.ts +1 -1
- package/dist/providers/quickbooks/api.js +28 -25
- package/dist/providers/quickbooks/index.js +1 -1
- package/dist/providers/quickbooks/lib/writes.js +55 -0
- package/dist/providers/quickbooks/safety.js +17 -5
- package/dist/providers/quickbooks/skill.generated.js +1 -1
- package/dist/providers/sheets/index.js +1 -1
- package/dist/providers/slides/index.js +1 -1
- package/dist/providers/stripe/index.js +2 -2
- package/dist/providers/tithely/index.js +1 -1
- package/dist/providers/vercel/api.js +1 -0
- package/dist/registry.js +16 -4
- package/dist/resource-read.js +21 -2
- package/dist/route-names.js +44 -0
- package/dist/routes/activity-context.js +1 -0
- package/dist/routes/activity-disclosure.js +26 -4
- package/dist/routes/activity.js +2 -1
- package/dist/routes/mcp.js +32 -13
- package/dist/routes/ui-config.js +1 -1
- package/dist/routes/ui-data.js +3 -2
- package/dist/runtime/native-storage.js +12 -0
- package/dist/runtime/request-reads.js +66 -14
- package/dist/runtime/request-scope.js +2 -1
- package/dist/skill-origin.js +16 -0
- package/dist/skills.js +132 -43
- package/dist/storage/d1-driver.js +33 -5
- package/dist/storage/keys.js +7 -1
- package/dist/storage/sql-native.js +60 -3
- package/dist/storage/sql.js +6 -0
- package/dist/timing-trace.js +84 -0
- package/dist/types.d.ts +5 -0
- package/dist/url-safety.js +38 -20
- package/dist/utility-recovery.js +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +33 -8
- package/documentation/architecture.md +48 -1
- package/documentation/auth.md +116 -36
- package/documentation/code-mode.md +61 -46
- package/documentation/deploying.md +25 -1
- package/documentation/meta-tools.md +113 -11
- package/documentation/operating.md +86 -1
- package/documentation/operator-ui.md +51 -2
- package/documentation/providers.md +16 -14
- package/package.json +10 -8
- package/templates/node/package.json +1 -1
|
@@ -99,10 +99,38 @@ const FLOW_VERSION = 1;
|
|
|
99
99
|
* flow record's fifteen minutes.
|
|
100
100
|
*/
|
|
101
101
|
const PENDING_AUTHORIZATION_MAX_AGE_MS = 10 * 60 * 1000;
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
/**
|
|
103
|
+
* What a flow record without a sealer may keep of a URL: everything but its
|
|
104
|
+
* userinfo, query and fragment. Non-URLs and clean URLs stay exact, so an
|
|
105
|
+
* ordinary issuer or client id still compares byte for byte.
|
|
106
|
+
*/
|
|
107
|
+
export function withoutUrlSecrets(value) {
|
|
108
|
+
if (Array.isArray(value))
|
|
109
|
+
return value.map(withoutUrlSecrets);
|
|
110
|
+
if (plainObject(value)) {
|
|
111
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withoutUrlSecrets(item)]));
|
|
112
|
+
}
|
|
113
|
+
if (typeof value !== "string")
|
|
114
|
+
return value;
|
|
115
|
+
try {
|
|
116
|
+
const url = new URL(value);
|
|
117
|
+
if (!url.username && !url.password && !url.search && !url.hash)
|
|
118
|
+
return value;
|
|
119
|
+
url.username = url.password = url.search = url.hash = "";
|
|
120
|
+
return url.href;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `minimized` binds the discovery metadata without its URL secrets, for a
|
|
128
|
+
* record no key protects: that digest is unkeyed, so it may not cover them.
|
|
129
|
+
*/
|
|
130
|
+
export function discoveryBinding(state, minimized = false) {
|
|
131
|
+
return runEdge(authProgram(discoveryBindingNative, [state, minimized]));
|
|
104
132
|
}
|
|
105
|
-
const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, function (state) {
|
|
133
|
+
const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, function (state, minimized = false) {
|
|
106
134
|
return authFlow(function* () {
|
|
107
135
|
return state
|
|
108
136
|
? {
|
|
@@ -111,7 +139,9 @@ const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, fu
|
|
|
111
139
|
...(state.authorizationServerMetadata?.authorization_response_iss_parameter_supported === true
|
|
112
140
|
? { issRequired: true }
|
|
113
141
|
: {}),
|
|
114
|
-
discovery: (yield authCall(oauthStateDigest, [
|
|
142
|
+
discovery: (yield authCall(oauthStateDigest, [
|
|
143
|
+
JSON.stringify(minimized ? withoutUrlSecrets(state) : state),
|
|
144
|
+
])),
|
|
115
145
|
}
|
|
116
146
|
: {};
|
|
117
147
|
});
|
|
@@ -120,20 +150,26 @@ const discoveryBindingNative = /* @__PURE__ */ registerAuth(discoveryBinding, fu
|
|
|
120
150
|
export const consentMethods = {
|
|
121
151
|
encodeFlow: function (key, flow) {
|
|
122
152
|
return authFlow(function* () {
|
|
123
|
-
const { verifier, ...rest } = flow;
|
|
153
|
+
const { binding, url, verifier, ...rest } = flow;
|
|
124
154
|
const stored = { connectaOAuthFlow: FLOW_VERSION, ...rest };
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
155
|
+
if (this.sealer) {
|
|
156
|
+
stored.record = (yield authInvoke(this, "wait", [
|
|
157
|
+
authMethod(() => {
|
|
158
|
+
return authInvoke(this.sealer, "seal", [
|
|
159
|
+
this.flowRecordLabel(key, flow.epoch),
|
|
160
|
+
JSON.stringify({ binding, url, verifier }),
|
|
161
|
+
]);
|
|
162
|
+
}),
|
|
163
|
+
]));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// No key to protect it with: keep only what the callback must read.
|
|
167
|
+
// The consent URL is the first thing to go, so a repeated start mints
|
|
168
|
+
// a fresh consent rather than handing this one back.
|
|
169
|
+
if (binding)
|
|
170
|
+
stored.binding = withoutUrlSecrets(binding);
|
|
171
|
+
if (verifier !== undefined)
|
|
135
172
|
stored.verifier = verifier;
|
|
136
|
-
}
|
|
137
173
|
}
|
|
138
174
|
return JSON.stringify(stored);
|
|
139
175
|
}.bind(this));
|
|
@@ -146,7 +182,8 @@ export const consentMethods = {
|
|
|
146
182
|
typeof value.epoch !== "string" ||
|
|
147
183
|
typeof value.at !== "number" ||
|
|
148
184
|
(value.url !== undefined && typeof value.url !== "string") ||
|
|
149
|
-
(value.verifier !== undefined && typeof value.verifier !== "string")
|
|
185
|
+
(value.verifier !== undefined && typeof value.verifier !== "string") ||
|
|
186
|
+
(value.record !== undefined && typeof value.record !== "string")) {
|
|
150
187
|
return undefined;
|
|
151
188
|
}
|
|
152
189
|
const flow = {
|
|
@@ -156,23 +193,32 @@ export const consentMethods = {
|
|
|
156
193
|
...(typeof value.url === "string" ? { url: value.url } : {}),
|
|
157
194
|
...(value.consumed === true ? { consumed: true } : {}),
|
|
158
195
|
};
|
|
159
|
-
|
|
196
|
+
const sealedRecord = typeof value.record === "string";
|
|
197
|
+
if (!sealedRecord && (!withVerifier || typeof value.verifier !== "string"))
|
|
160
198
|
return flow;
|
|
161
|
-
if (value.sealed !== true)
|
|
199
|
+
if (!sealedRecord && value.sealed !== true)
|
|
162
200
|
return { ...flow, verifier: value.verifier };
|
|
163
201
|
try {
|
|
164
202
|
if (!this.sealer)
|
|
165
203
|
return undefined;
|
|
204
|
+
const opened = (yield authInvoke(this, "wait", [
|
|
205
|
+
authMethod(() => {
|
|
206
|
+
return authInvoke(this.sealer, "open", [
|
|
207
|
+
sealedRecord ? this.flowRecordLabel(key, flow.epoch) : this.flowLabel(key, flow.epoch),
|
|
208
|
+
(sealedRecord ? value.record : value.verifier),
|
|
209
|
+
]);
|
|
210
|
+
}),
|
|
211
|
+
]));
|
|
212
|
+
if (!sealedRecord)
|
|
213
|
+
return { ...flow, verifier: opened };
|
|
214
|
+
const record = parsed(opened);
|
|
215
|
+
if (!plainObject(record))
|
|
216
|
+
return undefined;
|
|
166
217
|
return {
|
|
167
218
|
...flow,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
this.flowLabel(key, flow.epoch),
|
|
172
|
-
value.verifier,
|
|
173
|
-
]);
|
|
174
|
-
}),
|
|
175
|
-
])),
|
|
219
|
+
...(plainObject(record.binding) ? { binding: record.binding } : {}),
|
|
220
|
+
...(typeof record.url === "string" ? { url: record.url } : {}),
|
|
221
|
+
...(withVerifier && typeof record.verifier === "string" ? { verifier: record.verifier } : {}),
|
|
176
222
|
};
|
|
177
223
|
}
|
|
178
224
|
catch {
|
|
@@ -258,7 +304,10 @@ export const consentMethods = {
|
|
|
258
304
|
const binding = this.configuredAuthorizationBinding() ?? {
|
|
259
305
|
...(yield authInvoke(this, "wait", [
|
|
260
306
|
authMethod(() => {
|
|
261
|
-
return authCall(discoveryBinding, [
|
|
307
|
+
return authCall(discoveryBinding, [
|
|
308
|
+
this.consentDiscovery ?? grant.body.discovery,
|
|
309
|
+
!this.sealer,
|
|
310
|
+
]);
|
|
262
311
|
}),
|
|
263
312
|
])),
|
|
264
313
|
issuer: discoveryIssuer(this.consentDiscovery) ?? grant.body.issuer,
|
|
@@ -442,7 +491,8 @@ export const consentMethods = {
|
|
|
442
491
|
if (grant.epoch !== epoch)
|
|
443
492
|
throw this.flowSuperseded();
|
|
444
493
|
(yield authInvoke(this, "checkFlowBinding", [grant]));
|
|
445
|
-
if (endpoint !== undefined &&
|
|
494
|
+
if (endpoint !== undefined &&
|
|
495
|
+
this.flowValue(this.callback?.flow.binding?.tokenEndpoint) !== this.flowValue(String(endpoint))) {
|
|
446
496
|
throw this.flowSuperseded();
|
|
447
497
|
}
|
|
448
498
|
// Opening ciphertext may yield. Re-read the exact record after opening,
|
|
@@ -16,6 +16,15 @@ const DISCONNECTED_EPOCH_PREFIX = "disconnected:";
|
|
|
16
16
|
const GRANT_VERSION = 3;
|
|
17
17
|
/** Attempts at one grant write while other writers keep winning. */
|
|
18
18
|
export const MAX_GRANT_WRITES = 32;
|
|
19
|
+
/** Token commits and their status fence are the same CAS, never separate writes. */
|
|
20
|
+
export function nextTokenGrant(grant, body) {
|
|
21
|
+
const revision = (grant.tokenRevision ?? 0) + 1;
|
|
22
|
+
if (!Number.isSafeInteger(revision))
|
|
23
|
+
throw new Error("OAuth grant revision exhausted");
|
|
24
|
+
const next = { ...grant, body, tokenRevision: revision };
|
|
25
|
+
delete next.observation;
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
19
28
|
export function plainObject(value) {
|
|
20
29
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
30
|
}
|
|
@@ -32,6 +41,8 @@ function storedGrant(raw) {
|
|
|
32
41
|
return plainObject(value) &&
|
|
33
42
|
value.connectaOAuth === GRANT_VERSION &&
|
|
34
43
|
typeof value.epoch === "string" &&
|
|
44
|
+
(value.tokenRevision === undefined ||
|
|
45
|
+
(Number.isSafeInteger(value.tokenRevision) && value.tokenRevision >= 0)) &&
|
|
35
46
|
(value.flow === undefined || typeof value.flow === "string") &&
|
|
36
47
|
(value.sealed === undefined || typeof value.sealed === "string")
|
|
37
48
|
? value
|
|
@@ -105,6 +116,8 @@ export const grantStoreMethods = {
|
|
|
105
116
|
const stored = {
|
|
106
117
|
connectaOAuth: GRANT_VERSION,
|
|
107
118
|
epoch: grant.epoch,
|
|
119
|
+
...(grant.tokenRevision !== undefined ? { tokenRevision: grant.tokenRevision } : {}),
|
|
120
|
+
...(grant.observation ? { observation: grant.observation } : {}),
|
|
108
121
|
...(grant.flow !== undefined ? { flow: grant.flow } : {}),
|
|
109
122
|
};
|
|
110
123
|
if (Object.keys(grant.body).length > 0) {
|
|
@@ -160,6 +173,11 @@ export const grantStoreMethods = {
|
|
|
160
173
|
}
|
|
161
174
|
const grant = {
|
|
162
175
|
epoch: stored.epoch,
|
|
176
|
+
...(stored.tokenRevision !== undefined ? { tokenRevision: stored.tokenRevision } : {}),
|
|
177
|
+
...(stored.observation?.revision === (stored.tokenRevision ?? 0) &&
|
|
178
|
+
(stored.observation.outcome === "accepted" || stored.observation.outcome === "refused")
|
|
179
|
+
? { observation: { revision: stored.observation.revision, outcome: stored.observation.outcome } }
|
|
180
|
+
: {}),
|
|
163
181
|
...(stored.flow !== undefined ? { flow: stored.flow } : {}),
|
|
164
182
|
body: grantBody(body),
|
|
165
183
|
};
|
|
@@ -189,7 +207,12 @@ export const grantStoreMethods = {
|
|
|
189
207
|
authMethod(() => authStorage(this.storage, "get", [GRANT])),
|
|
190
208
|
]));
|
|
191
209
|
const grant = (yield authInvoke(this, "openGrant", [raw]));
|
|
192
|
-
return {
|
|
210
|
+
return {
|
|
211
|
+
epoch: grant.epoch,
|
|
212
|
+
...(grant.body.tokens ? { tokens: grant.body.tokens } : {}),
|
|
213
|
+
...(grant.body.issuer !== undefined ? { issuer: grant.body.issuer } : {}),
|
|
214
|
+
...(grant.observation ? { observation: grant.observation } : {}),
|
|
215
|
+
};
|
|
193
216
|
}.bind(this));
|
|
194
217
|
},
|
|
195
218
|
readGrant: function (signal) {
|
|
@@ -309,7 +332,8 @@ export const grantStoreMethods = {
|
|
|
309
332
|
if (refreshConsent)
|
|
310
333
|
body.refreshConsent = refreshConsent;
|
|
311
334
|
}
|
|
312
|
-
|
|
335
|
+
const next = this.withIssuer(grant, body);
|
|
336
|
+
return field === "tokens" ? nextTokenGrant(next, body) : next;
|
|
313
337
|
},
|
|
314
338
|
]));
|
|
315
339
|
if (field === "tokens")
|
|
@@ -318,6 +342,20 @@ export const grantStoreMethods = {
|
|
|
318
342
|
this.seen[field] = fingerprint(field === "client" ? value.value : value);
|
|
319
343
|
}.bind(this));
|
|
320
344
|
},
|
|
345
|
+
recordTokenOutcome: function (snapshot, outcome) {
|
|
346
|
+
if (outcome !== "accepted" && outcome !== "refused")
|
|
347
|
+
return Effect.void;
|
|
348
|
+
return authInvoke(this, "updateGrant", [
|
|
349
|
+
(grant) => {
|
|
350
|
+
if (grant.epoch !== snapshot.epoch || (grant.tokenRevision ?? 0) !== snapshot.revision || !grant.body.tokens)
|
|
351
|
+
return undefined;
|
|
352
|
+
if (grant.observation?.revision === snapshot.revision && grant.observation.outcome === outcome)
|
|
353
|
+
return undefined;
|
|
354
|
+
return { ...grant, observation: { revision: snapshot.revision, outcome } };
|
|
355
|
+
},
|
|
356
|
+
{ epoch: snapshot.epoch, quiet: true },
|
|
357
|
+
]);
|
|
358
|
+
},
|
|
321
359
|
bindingForGrant: function (grant) {
|
|
322
360
|
return authFlow(function* () {
|
|
323
361
|
const configured = this.configuredAuthorizationBinding();
|
|
@@ -326,7 +364,7 @@ export const grantStoreMethods = {
|
|
|
326
364
|
: {
|
|
327
365
|
...(yield authInvoke(this, "wait", [
|
|
328
366
|
authMethod(() => {
|
|
329
|
-
return authCall(discoveryBinding, [grant.body.discovery]);
|
|
367
|
+
return authCall(discoveryBinding, [grant.body.discovery, !this.sealer]);
|
|
330
368
|
}),
|
|
331
369
|
])),
|
|
332
370
|
issuer: grant.body.issuer,
|
|
@@ -338,7 +376,8 @@ export const grantStoreMethods = {
|
|
|
338
376
|
return authFlow(function* () {
|
|
339
377
|
const binding = this.callback?.flow.binding;
|
|
340
378
|
if (!binding ||
|
|
341
|
-
fingerprint(binding) !==
|
|
379
|
+
fingerprint(this.flowValue(binding)) !==
|
|
380
|
+
fingerprint(this.flowValue((yield authInvoke(this, "bindingForGrant", [grant]))))) {
|
|
342
381
|
throw this.flowSuperseded();
|
|
343
382
|
}
|
|
344
383
|
}.bind(this));
|
|
@@ -384,9 +423,14 @@ export const grantStoreMethods = {
|
|
|
384
423
|
};
|
|
385
424
|
}.bind(this));
|
|
386
425
|
},
|
|
387
|
-
resetAuthorization: function (operatorDisconnected = false, preserveClient = false) {
|
|
426
|
+
resetAuthorization: function (operatorDisconnected = false, preserveClient = false, refused) {
|
|
388
427
|
return authFlow(function* () {
|
|
389
|
-
const operation = authInvoke(this, "performReset", [
|
|
428
|
+
const operation = authInvoke(this, "performReset", [
|
|
429
|
+
operatorDisconnected,
|
|
430
|
+
preserveClient,
|
|
431
|
+
undefined,
|
|
432
|
+
refused,
|
|
433
|
+
]);
|
|
390
434
|
if (!this.onReset)
|
|
391
435
|
return (yield operation);
|
|
392
436
|
const reset = runEdge(operation);
|
|
@@ -404,11 +448,18 @@ export const grantStoreMethods = {
|
|
|
404
448
|
return (yield authValue(() => reset));
|
|
405
449
|
}.bind(this));
|
|
406
450
|
},
|
|
407
|
-
performReset: function (operatorDisconnected, preserveClient, revoke) {
|
|
451
|
+
performReset: function (operatorDisconnected, preserveClient, revoke, refused) {
|
|
408
452
|
return authFlow(function* () {
|
|
409
453
|
const epoch = `${operatorDisconnected ? DISCONNECTED_EPOCH_PREFIX : ACTIVE_EPOCH_PREFIX}${crypto.randomUUID()}`;
|
|
410
454
|
for (let attempt = 0; attempt < MAX_GRANT_WRITES; attempt++) {
|
|
411
455
|
const { raw, grant } = (yield authInvoke(this, "readGrant", [null]));
|
|
456
|
+
// Compare again on every CAS attempt: recovery may have changed the
|
|
457
|
+
// epoch, token revision, or observation since Connect read it.
|
|
458
|
+
if (refused &&
|
|
459
|
+
(grant.epoch !== refused.epoch ||
|
|
460
|
+
(grant.tokenRevision ?? 0) !== refused.revision ||
|
|
461
|
+
grant.observation?.outcome !== "refused"))
|
|
462
|
+
return;
|
|
412
463
|
const carried = preserveClient && !operatorDisconnected ? this.carriableClient(grant) : undefined;
|
|
413
464
|
const encoded = (yield authInvoke(this, "encodeGrant", [{ epoch, body: carried ?? {} }, null]));
|
|
414
465
|
let written;
|
|
@@ -4,7 +4,7 @@ import { fromSignal } from "../../runtime/run.js";
|
|
|
4
4
|
import { OAUTH_REFRESH_LEASE_SECONDS, oauthRefreshActiveKeys, oauthRefreshKeys, oauthRefreshSpentKeys, } from "../../storage/keys.js";
|
|
5
5
|
import { authCall, authCatch, authFlow, authInvoke, authMethod, authStorage, authStorageMany } from "../native.js";
|
|
6
6
|
import { tokenExpiry } from "../oauth-grant.js";
|
|
7
|
-
import { GRANT, MAX_GRANT_WRITES, epochOf, parsed, plainObject } from "./grant-store.js";
|
|
7
|
+
import { nextTokenGrant, GRANT, MAX_GRANT_WRITES, epochOf, parsed, plainObject } from "./grant-store.js";
|
|
8
8
|
import { aborted, refreshTokensDigest, refreshWait } from "./refresh-coordinator.js";
|
|
9
9
|
import { oauthStateDigest } from "./consent.js";
|
|
10
10
|
export function storedRefreshSpent(raw) {
|
|
@@ -378,10 +378,11 @@ export const refreshRecordMethods = {
|
|
|
378
378
|
const committed = issuer !== undefined ? { ...tokens, issuer } : tokens;
|
|
379
379
|
const { expiresAt: _previous, ...body } = grant.body;
|
|
380
380
|
const expiresAt = tokenExpiry(committed, issuedAt);
|
|
381
|
-
const next = {
|
|
382
|
-
...
|
|
383
|
-
|
|
384
|
-
|
|
381
|
+
const next = nextTokenGrant(grant, {
|
|
382
|
+
...body,
|
|
383
|
+
tokens: committed,
|
|
384
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
385
|
+
});
|
|
385
386
|
const encoded = (yield authInvoke(this, "encodeGrant", [next, null]));
|
|
386
387
|
if ((yield authStorage(this.storage, "compareAndSet", [GRANT, raw, encoded]))) {
|
|
387
388
|
this.observe(next);
|
|
@@ -6,7 +6,7 @@ import { SentSecrets } from "../sent-secrets.js";
|
|
|
6
6
|
import { authenticateRemoteClient, downstreamClientMetadata, remoteClientAuthMethod, } from "./downstream-client-metadata.js";
|
|
7
7
|
import { authMethods } from "./native.js";
|
|
8
8
|
import { OAUTH_REFRESH_SKEW_MS } from "./oauth-grant.js";
|
|
9
|
-
import { consentMethods } from "./downstream-oauth/consent.js";
|
|
9
|
+
import { consentMethods, withoutUrlSecrets } from "./downstream-oauth/consent.js";
|
|
10
10
|
import { ACTIVE_EPOCH_PREFIX, GRANT, grantStoreMethods, isDisconnectedEpoch, } from "./downstream-oauth/grant-store.js";
|
|
11
11
|
import { aborted, tokenResponse, } from "./downstream-oauth/refresh-coordinator.js";
|
|
12
12
|
import { refreshRecordMethods } from "./downstream-oauth/refresh-records.js";
|
|
@@ -32,8 +32,7 @@ export function authorizingContext(ctx) {
|
|
|
32
32
|
return deriveContext(ctx, { allowAuthorization: true });
|
|
33
33
|
}
|
|
34
34
|
/** A local destination guard proved that no OAuth HTTP request left. */
|
|
35
|
-
export
|
|
36
|
-
}
|
|
35
|
+
export { OAuthRequestNotSentError } from "./oauth-request-not-sent.js";
|
|
37
36
|
/**
|
|
38
37
|
* OAuthClientProvider over one owner's grant record and its consents' flow
|
|
39
38
|
* records, in one connector's namespace.
|
|
@@ -157,6 +156,7 @@ export class KvOAuthProvider {
|
|
|
157
156
|
this.observed = tokens
|
|
158
157
|
? {
|
|
159
158
|
epoch: grant.epoch,
|
|
159
|
+
revision: grant.tokenRevision ?? 0,
|
|
160
160
|
accessToken: tokens.access_token,
|
|
161
161
|
refreshable: typeof tokens.refresh_token === "string" && tokens.refresh_token !== "",
|
|
162
162
|
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
@@ -195,7 +195,7 @@ export class KvOAuthProvider {
|
|
|
195
195
|
if (!this.callback ||
|
|
196
196
|
(issuer === null
|
|
197
197
|
? this.callback.flow.binding?.issRequired === true
|
|
198
|
-
: issuer !== this.callback.flow.binding?.issuer)) {
|
|
198
|
+
: this.flowValue(issuer) !== this.flowValue(this.callback.flow.binding?.issuer))) {
|
|
199
199
|
throw new ConnectorCallError("connector_call_failed", `Connector "${this.connectorId}" authorization callback issuer does not match its consent; nothing was exchanged.`);
|
|
200
200
|
}
|
|
201
201
|
}
|
|
@@ -203,6 +203,14 @@ export class KvOAuthProvider {
|
|
|
203
203
|
flowLabel(key, epoch) {
|
|
204
204
|
return `${key}:${epoch}`;
|
|
205
205
|
}
|
|
206
|
+
/** The sealed record's own label, so a verifier's ciphertext cannot stand in for it. */
|
|
207
|
+
flowRecordLabel(key, epoch) {
|
|
208
|
+
return `${this.flowLabel(key, epoch)}:record`;
|
|
209
|
+
}
|
|
210
|
+
/** A binding value as this provider's flow records hold it, for comparison. */
|
|
211
|
+
flowValue(value) {
|
|
212
|
+
return this.sealer ? value : withoutUrlSecrets(value);
|
|
213
|
+
}
|
|
206
214
|
isOperatorDisconnectedEpoch(epoch) {
|
|
207
215
|
return isDisconnectedEpoch(epoch);
|
|
208
216
|
}
|
|
@@ -547,12 +547,14 @@ export function staticOAuth(id, config) {
|
|
|
547
547
|
// A token that lives under a minute is due again at once: the invocation's
|
|
548
548
|
// check renews it, and the handler sends it while it is unexpired.
|
|
549
549
|
const earlyRenewed = new EarlyRenewals();
|
|
550
|
-
/**
|
|
551
|
-
* One call's grant use; submitted refresh work keeps the coordinator's owner.
|
|
552
|
-
* `renewAgain` is the invocation's check, which renews any due token,
|
|
553
|
-
* even one this request's earlier renewal produced.
|
|
554
|
-
*/
|
|
555
550
|
const grantFor = (ctx, requestOwned = false, renewAgain = false) => {
|
|
551
|
+
const observations = new Map();
|
|
552
|
+
const capture = (provider, token) => {
|
|
553
|
+
const seen = provider.observed;
|
|
554
|
+
if (seen?.accessToken === token)
|
|
555
|
+
observations.set(token, { epoch: seen.epoch, revision: seen.revision });
|
|
556
|
+
return token;
|
|
557
|
+
};
|
|
556
558
|
let provider;
|
|
557
559
|
const use = authMethod((work) => {
|
|
558
560
|
return authView(ctx, authMethod((signal) => {
|
|
@@ -611,9 +613,9 @@ export function staticOAuth(id, config) {
|
|
|
611
613
|
true,
|
|
612
614
|
]));
|
|
613
615
|
earlyRenewed.record(scope, tokens.access_token, fresh, provider.observedExpiry(fresh));
|
|
614
|
-
return fresh;
|
|
616
|
+
return capture(provider, fresh);
|
|
615
617
|
}
|
|
616
|
-
return tokens.access_token;
|
|
618
|
+
return capture(provider, tokens.access_token);
|
|
617
619
|
});
|
|
618
620
|
}),
|
|
619
621
|
]);
|
|
@@ -621,10 +623,21 @@ export function staticOAuth(id, config) {
|
|
|
621
623
|
refresh: authMethod((stale) => {
|
|
622
624
|
return authCall(use, [
|
|
623
625
|
authMethod((provider, live) => {
|
|
624
|
-
return
|
|
626
|
+
return authFlow(function* () {
|
|
627
|
+
const token = (yield authCall(renewedToken, [provider, live, stale, requestOwned]));
|
|
628
|
+
return capture(provider, token);
|
|
629
|
+
});
|
|
625
630
|
}),
|
|
626
631
|
]);
|
|
627
632
|
}),
|
|
633
|
+
observe: authMethod((token, outcome) => {
|
|
634
|
+
const snapshot = observations.get(token);
|
|
635
|
+
if (!snapshot)
|
|
636
|
+
return Effect.void;
|
|
637
|
+
return authCall(use, [
|
|
638
|
+
authMethod((provider) => authInvoke(provider, "recordTokenOutcome", [snapshot, outcome])),
|
|
639
|
+
]).pipe(Effect.asVoid, Effect.catch(() => Effect.void));
|
|
640
|
+
}),
|
|
628
641
|
rejected: authMethod(() => {
|
|
629
642
|
return authView(ctx, authMethod(() => {
|
|
630
643
|
return authValue(() => {
|
|
@@ -735,6 +748,10 @@ export function staticOAuth(id, config) {
|
|
|
735
748
|
const grant = grantFor(ctx, auxiliary);
|
|
736
749
|
const stale = (yield authInvoke(grant, "token", []));
|
|
737
750
|
const { response: first, dispatch } = (yield authCall(send, [stale]));
|
|
751
|
+
if (first.status === 401 || (first.status >= 200 && first.status < 300))
|
|
752
|
+
yield authInvoke(grant, "observe", [stale, first.status === 401 ? "refused" : "accepted"]);
|
|
753
|
+
if (signal.aborted)
|
|
754
|
+
throw signal.reason;
|
|
738
755
|
if (first.status !== 401)
|
|
739
756
|
return first;
|
|
740
757
|
try {
|
|
@@ -773,6 +790,10 @@ export function staticOAuth(id, config) {
|
|
|
773
790
|
throw signal.reason;
|
|
774
791
|
renewedAfterUnauthorized(dispatch, stale, accessToken);
|
|
775
792
|
const { response: second } = (yield authCall(send, [accessToken]));
|
|
793
|
+
if (second.status === 401 || (second.status >= 200 && second.status < 300))
|
|
794
|
+
yield authInvoke(grant, "observe", [accessToken, second.status === 401 ? "refused" : "accepted"]);
|
|
795
|
+
if (signal.aborted)
|
|
796
|
+
throw signal.reason;
|
|
776
797
|
if (second.status !== 401)
|
|
777
798
|
return second;
|
|
778
799
|
void second.body?.cancel().catch(() => { });
|
|
@@ -800,7 +821,7 @@ export function staticOAuth(id, config) {
|
|
|
800
821
|
}), auxiliary));
|
|
801
822
|
});
|
|
802
823
|
});
|
|
803
|
-
const statusOf = authMethod((ctx, provider) => {
|
|
824
|
+
const statusOf = authMethod((ctx, provider, forCall = false) => {
|
|
804
825
|
return authFlow(function* () {
|
|
805
826
|
if (rejectedScopes.has(scopeOf(ctx))) {
|
|
806
827
|
return {
|
|
@@ -808,16 +829,18 @@ export function staticOAuth(id, config) {
|
|
|
808
829
|
message: "Authorization required \u2014 the downstream rejected this connector's OAuth grant.",
|
|
809
830
|
};
|
|
810
831
|
}
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
// asked nothing.
|
|
815
|
-
if ((yield authInvoke(provider, "operatorDisconnected", []))) {
|
|
832
|
+
// Peek opens the current grant without migration writes or authorization.
|
|
833
|
+
const grant = (yield authInvoke(provider, "peekGrant", []));
|
|
834
|
+
if (provider.isOperatorDisconnectedEpoch(grant.epoch))
|
|
816
835
|
return { state: "auth_required", message: disconnectedMessage };
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
836
|
+
if (!grant.tokens || grant.issuer !== settings.identity)
|
|
837
|
+
return { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
|
|
838
|
+
return !forCall && grant.observation?.outcome === "refused"
|
|
839
|
+
? {
|
|
840
|
+
state: "auth_required",
|
|
841
|
+
message: "Authorization required — the downstream rejected this connector's OAuth grant.",
|
|
842
|
+
}
|
|
843
|
+
: { state: "ok" };
|
|
821
844
|
});
|
|
822
845
|
});
|
|
823
846
|
const status = authMethod((ctx) => {
|
|
@@ -832,7 +855,9 @@ export function staticOAuth(id, config) {
|
|
|
832
855
|
const prepareCall = authMethod((ctx) => {
|
|
833
856
|
return authFlow(function* () {
|
|
834
857
|
const provider = providerFor(ctx);
|
|
835
|
-
|
|
858
|
+
// A persisted API refusal is diagnostic, not proof the next request will fail.
|
|
859
|
+
// Keep the request-local latch, but let a later invocation observe recovery.
|
|
860
|
+
const state = (yield authCall(statusOf, [ctx, provider, true]));
|
|
836
861
|
if (state.state === "ok" && provider.renewalDue() !== undefined)
|
|
837
862
|
(yield authInvoke(grantFor(ctx, false, true), "token", []));
|
|
838
863
|
return state;
|
|
@@ -849,8 +874,13 @@ export function staticOAuth(id, config) {
|
|
|
849
874
|
: (yield authInvoke(provider, "operatorDisconnected", []));
|
|
850
875
|
if (ctx.signal?.aborted)
|
|
851
876
|
throw ctx.signal.reason;
|
|
852
|
-
|
|
853
|
-
|
|
877
|
+
const observed = (yield authInvoke(provider, "peekGrant", []));
|
|
878
|
+
const refused = observed.observation?.outcome === "refused";
|
|
879
|
+
if (opts?.force || disconnected || refused || rejectedScopes.has(scopeOf(original))) {
|
|
880
|
+
const fence = !opts?.force && !disconnected && refused
|
|
881
|
+
? { epoch: observed.epoch, revision: observed.observation.revision }
|
|
882
|
+
: undefined;
|
|
883
|
+
(yield authInvoke(provider, "resetAuthorization", [false, false, fence]));
|
|
854
884
|
rejectedScopes.delete(scopeOf(original));
|
|
855
885
|
}
|
|
856
886
|
else {
|