@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/bin/version.mjs +1 -1
  3. package/dist/activity.d.ts +30 -4
  4. package/dist/activity.js +28 -2
  5. package/dist/agent-observation.js +24 -2
  6. package/dist/agent-routes.js +4 -41
  7. package/dist/auth/clerk.d.ts +6 -1
  8. package/dist/auth/clerk.js +16 -0
  9. package/dist/auth/cloudflare-access.js +49 -7
  10. package/dist/auth/downstream-oauth/consent.js +79 -29
  11. package/dist/auth/downstream-oauth/grant-store.js +58 -7
  12. package/dist/auth/downstream-oauth/refresh-records.js +6 -5
  13. package/dist/auth/downstream-oauth.js +12 -4
  14. package/dist/auth/oauth-request-not-sent.js +4 -0
  15. package/dist/auth/static-oauth.js +51 -21
  16. package/dist/catalog-cache.js +62 -21
  17. package/dist/catalog-freshness.js +13 -0
  18. package/dist/catalog-service.js +39 -12
  19. package/dist/catalog.js +103 -38
  20. package/dist/config.d.ts +10 -4
  21. package/dist/config.js +30 -7
  22. package/dist/connectors/remote-mcp/call.js +1 -1
  23. package/dist/connectors/remote-mcp/connection.js +52 -18
  24. package/dist/connectors/remote-mcp/oauth.js +2 -2
  25. package/dist/connectors/remote-mcp/resources.js +5 -2
  26. package/dist/connectors/remote-mcp/transport.js +24 -6
  27. package/dist/connectors/remote-mcp.js +4 -4
  28. package/dist/credential-contract.d.ts +3 -1
  29. package/dist/describe-config.d.ts +1 -0
  30. package/dist/describe-config.js +1 -0
  31. package/dist/downstream-input.js +11 -2
  32. package/dist/errors.d.ts +8 -1
  33. package/dist/errors.js +2 -0
  34. package/dist/execute.js +20 -7
  35. package/dist/executor-result.js +1 -1
  36. package/dist/executors/quickjs-child.js +1 -1
  37. package/dist/executors/quickjs-runtime.js +1 -1
  38. package/dist/executors/quickjs.js +1 -1
  39. package/dist/guarded-transport.js +2 -0
  40. package/dist/http/client.js +7 -0
  41. package/dist/http/hatch.js +2 -0
  42. package/dist/http/node-transport.js +110 -4
  43. package/dist/http/oauth.js +17 -6
  44. package/dist/index.js +10 -21
  45. package/dist/invocation-failure.js +28 -0
  46. package/dist/invocation.js +72 -33
  47. package/dist/membership.d.ts +1 -1
  48. package/dist/meta-tools.js +27 -13
  49. package/dist/module-contracts.d.ts +3 -1
  50. package/dist/native-tools.js +11 -4
  51. package/dist/node.d.ts +7 -0
  52. package/dist/node.js +82 -3
  53. package/dist/operation-activity.js +103 -0
  54. package/dist/operator-record.d.ts +8 -0
  55. package/dist/operator-record.js +15 -0
  56. package/dist/operator-ui/generated.js +2 -2
  57. package/dist/provider.d.ts +1 -1
  58. package/dist/providers/_shared/google/workspace.d.ts +1 -1
  59. package/dist/providers/breeze/index.js +1 -1
  60. package/dist/providers/ccb/index.js +1 -1
  61. package/dist/providers/cloudflare/index.js +1 -1
  62. package/dist/providers/docs/index.js +1 -1
  63. package/dist/providers/drive/index.js +1 -1
  64. package/dist/providers/forms/index.js +1 -1
  65. package/dist/providers/github/index.js +1 -1
  66. package/dist/providers/gmail/index.js +1 -1
  67. package/dist/providers/granola/index.js +1 -1
  68. package/dist/providers/overflow/index.js +1 -1
  69. package/dist/providers/planning-center/index.js +1 -1
  70. package/dist/providers/planning-center/lib/constants.d.ts +1 -1
  71. package/dist/providers/quickbooks/api.js +28 -25
  72. package/dist/providers/quickbooks/index.js +1 -1
  73. package/dist/providers/quickbooks/lib/writes.js +55 -0
  74. package/dist/providers/quickbooks/safety.js +17 -5
  75. package/dist/providers/quickbooks/skill.generated.js +1 -1
  76. package/dist/providers/sheets/index.js +1 -1
  77. package/dist/providers/slides/index.js +1 -1
  78. package/dist/providers/stripe/index.js +2 -2
  79. package/dist/providers/tithely/index.js +1 -1
  80. package/dist/providers/vercel/api.js +1 -0
  81. package/dist/registry.js +16 -4
  82. package/dist/resource-read.js +21 -2
  83. package/dist/route-names.js +44 -0
  84. package/dist/routes/activity-context.js +1 -0
  85. package/dist/routes/activity-disclosure.js +26 -4
  86. package/dist/routes/activity.js +2 -1
  87. package/dist/routes/mcp.js +32 -13
  88. package/dist/routes/ui-config.js +1 -1
  89. package/dist/routes/ui-data.js +3 -2
  90. package/dist/runtime/native-storage.js +12 -0
  91. package/dist/runtime/request-reads.js +66 -14
  92. package/dist/runtime/request-scope.js +2 -1
  93. package/dist/skill-origin.js +16 -0
  94. package/dist/skills.js +132 -43
  95. package/dist/storage/d1-driver.js +33 -5
  96. package/dist/storage/keys.js +7 -1
  97. package/dist/storage/sql-native.js +60 -3
  98. package/dist/storage/sql.js +6 -0
  99. package/dist/timing-trace.js +84 -0
  100. package/dist/types.d.ts +5 -0
  101. package/dist/url-safety.js +38 -20
  102. package/dist/utility-recovery.js +33 -0
  103. package/dist/version.d.ts +1 -1
  104. package/dist/version.js +1 -1
  105. package/dist/worker.js +33 -8
  106. package/documentation/architecture.md +48 -1
  107. package/documentation/auth.md +116 -36
  108. package/documentation/code-mode.md +61 -46
  109. package/documentation/deploying.md +25 -1
  110. package/documentation/meta-tools.md +113 -11
  111. package/documentation/operating.md +86 -1
  112. package/documentation/operator-ui.md +51 -2
  113. package/documentation/providers.md +16 -14
  114. package/package.json +10 -8
  115. 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
- export function discoveryBinding(state) {
103
- return runEdge(authProgram(discoveryBindingNative, [state]));
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, [JSON.stringify(state)])),
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 (verifier !== undefined) {
126
- if (this.sealer) {
127
- stored.verifier = (yield authInvoke(this, "wait", [
128
- authMethod(() => {
129
- return authInvoke(this.sealer, "seal", [this.flowLabel(key, flow.epoch), verifier]);
130
- }),
131
- ]));
132
- stored.sealed = true;
133
- }
134
- else {
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
- if (!withVerifier || typeof value.verifier !== "string")
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
- verifier: (yield authInvoke(this, "wait", [
169
- authMethod(() => {
170
- return authInvoke(this.sealer, "open", [
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, [this.consentDiscovery ?? grant.body.discovery]);
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 && this.callback?.flow.binding?.tokenEndpoint !== String(endpoint)) {
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 { epoch: grant.epoch, ...(grant.body.tokens ? { tokens: grant.body.tokens } : {}) };
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
- return this.withIssuer(grant, body);
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) !== fingerprint((yield authInvoke(this, "bindingForGrant", [grant])))) {
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", [operatorDisconnected, preserveClient]);
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
- ...grant,
383
- body: { ...body, tokens: committed, ...(expiresAt !== undefined ? { expiresAt } : {}) },
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 class OAuthRequestNotSentError extends ConnectorCallError {
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
  }
@@ -0,0 +1,4 @@
1
+ import { ConnectorCallError } from "../errors.js";
2
+ /** A local destination guard proved that no OAuth HTTP request left. */
3
+ export class OAuthRequestNotSentError extends ConnectorCallError {
4
+ }
@@ -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 authCall(renewedToken, [provider, live, stale, requestOwned]);
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
- // A status read never starts authorization: this provider cannot, and
812
- // nothing here calls the SDK. A stored grant for the configured token
813
- // endpoint is healthy until a call says otherwise — the downstream is
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
- return (yield authInvoke(provider, "tokens", [issuerContext]))
819
- ? { state: "ok" }
820
- : { state: "auth_required", message: AUTH_REQUIRED_MESSAGE };
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
- const state = (yield authCall(statusOf, [ctx, provider]));
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
- if (opts?.force || disconnected) {
853
- (yield authInvoke(provider, "resetAuthorization", []));
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 {